地理院標高タイルダウンローダー(ソースコードと実行結果)

概要

国土地理院が配信する標高タイル(PNG形式)を、緯度・経度とズームレベルを指定して取得するプログラムを扱う。取得したタイルはPNG画像のRGB値に標高値が埋め込まれており、これをデコードして標高の分布を画面に表示し、画像とメタデータをファイルに保存する。地図プレビューにより、いま指定している位置が日本地図のどこに当たるかを確認しながら操作できる。

目次

関連する外部ページ

サイト内の関連情報

第1章 Python開発環境,ライブラリ類

ここでは、最低限の事前準備について説明する。機械学習や深層学習を行う場合は、NVIDIA CUDA、Visual Studio、Cursorなどを追加でインストールすると便利である。これらについては別ページ https://www.kkaneko.jp/cc/dev/aiassist.html で解説しているので、必要に応じて参照すること。

第2章 Python 3.12 のインストール

Pythonのインストールを行い、Pythonのプログラムを実行する環境を整える。扱う環境は、Windows搭載パソコンである。金子研究室では、Python 3.12.10を推奨する。

[Windows での Python 3.12 のインストール手順を見るには、ここをクリック]

Windows での Python 3.12 のインストール

以下のいずれかの方法でPython 3.12をインストールする。Pythonがインストール済みの場合、この手順は不要である。

方法 1:winget によるインストール

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動するとPATHが反映される。

REM Python 3.12 をシステム領域にインストール
winget install --id Python.Python.3.12 -e --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1 Include_launcher=1 InstallLauncherAllUsers=1 TargetDir=\"C:\Program Files\Python312\""
if not "%ERRORLEVEL%"=="0" ( color 0c & echo Python 3.12 のインストールに失敗しました & ping 127.0.0.1 -n 6 >nul & color )

REM Python と Scripts を PATH 先頭に追加
powershell -NoProfile -Command "$p='C:\Program Files\Python312'; $s=\"$p\Scripts\"; if(Test-Path $p){$k=[Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment',$true); $c=$k.GetValue('Path','',[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames); $t=$k.GetValueKind('Path'); $new=$c; if((';'+$new+';') -notlike \"*;$p;*\"){$new=$p+';'+$new}; if((';'+$new+';') -notlike \"*;$s;*\"){$new=$s+';'+$new}; if($new -ne $c){$k.SetValue('Path',$new,$t)}; $k.Close()}"

REM 現在のセッションにも反映(システムPATHを再取得して連結)
for /f "usebackq tokens=2,*" %A in (`reg query "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /v Path`) do set "PATH=%B"

REM pip / wheel の更新
python -m pip install --no-user -U pip wheel
if not "%ERRORLEVEL%"=="0" ( color 0c & echo pip / wheel の更新に失敗しました & ping 127.0.0.1 -n 6 >nul & color )

方法 2:インストーラーによるインストール

  1. Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
  2. ダウンロードしたインストーラーを実行する。
  3. 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから python コマンドを実行できない。
  4. 「Install Python 3.xx for all users」にチェックを入れ、「Install」をクリックする。

インストールの確認

コマンドプロンプトで以下を実行する。

python --version

バージョン番号(例:Python 3.12.x)が表示されればインストール成功である。「'python' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。

第3章 Python の開発環境 Visual Studio Code のインストールと Python 用の設定

Python の開発環境Visual Studio Code(プログラムを編集するソフトウェア。以下、VS Code)を整える。

[Windows での Visual Studio Code のインストールと Python 用の設定手順を見るには、ここをクリック]

Windows での Visual Studio Code のインストールと Python 用の設定手順

1. VS Code と拡張機能のインストール

以下のコマンドにより,既存の VS Code を削除し,全ユーザー共有の設定で再インストールしたうえで,拡張機能(VS Code に機能を追加するソフトウェア)をまとめて導入する.

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして,コマンド全体をコマンドプロンプトにコピー&ペーストする。

インストールコマンド


REM ============================================================
REM Microsoft Visual Studio Code
REM ============================================================
REM Build Tools + Desktop development with C++(VCTools)+ 追加コンポーネント(一括)
REM 未インストール時: winget で新規インストール
REM インストール済み時: setup.exe modify でコンポーネント追加(バージョンは変更しない)
winget list --id Microsoft.VisualStudio.BuildTools 2>nul | findstr /i "BuildTools" >nul 2>&1
if %ERRORLEVEL% EQU 0 (
    for /f "usebackq delims=" %P in (`"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products Microsoft.VisualStudio.Product.BuildTools -property installationPath`) do start /wait "" "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" modify --installPath "%P" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100 --add Microsoft.VisualStudio.Component.VC.v143.x86.x64 --includeRecommended --quiet --norestart --nocache
    if not "%ERRORLEVEL%"=="0" ( color 0c & echo Build Tools のコンポーネント追加に失敗しました & ping 127.0.0.1 -n 6 >nul & color )
) else (
    winget install --scope machine --id Microsoft.VisualStudio.BuildTools -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "--quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100 --add Microsoft.VisualStudio.Component.VC.v143.x86.x64"
    if not "%ERRORLEVEL%"=="0" ( color 0c & echo Build Tools のインストールに失敗しました & ping 127.0.0.1 -n 6 >nul & color )
)

REM 全ユーザー共有の拡張機能フォルダ
if not exist "C:\ProgramData\vscode-extensions" mkdir "C:\ProgramData\vscode-extensions"
icacls "C:\ProgramData\vscode-extensions" /grant "Everyone:(OI)(CI)M" /T

REM スタートメニューのショートカットを --extensions-dir 付きで再作成
if exist "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code" rmdir /s /q "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code"
if exist "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk" del "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk"
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); $lnk.TargetPath='C:\Program Files\Microsoft VS Code\Code.exe'; $lnk.Arguments='--extensions-dir \"C:\ProgramData\vscode-extensions\"'; $lnk.Save()"
REM ショートカットの検証
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); Write-Host 'TargetPath:' $lnk.TargetPath; Write-Host 'Arguments:' $lnk.Arguments"

REM ファイル / フォルダ右クリックの「Code で開く」を登録
reg add "HKLM\SOFTWARE\Classes\*\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\Background\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%V\"" /f

REM --extensions-dir 付きで起動する code.cmd ラッパを作成
REM (%* を echo で書くと対話的 cmd で失われるため、PowerShell で [char]37+'*' を書き出す)
powershell -NoProfile -Command "$pct=[char]37; $q=[char]34; $c='@echo off'+[char]13+[char]10+$q+'C:\Program Files\Microsoft VS Code\bin\code.cmd'+$q+' --extensions-dir '+$q+'C:\ProgramData\vscode-extensions'+$q+' '+$pct+'*'+[char]13+[char]10; [IO.File]::WriteAllText('C:\ProgramData\vscode-extensions\vscode.cmd',$c,[Text.Encoding]::ASCII)"

REM 拡張機能のインストール
set "CODE=C:\Program Files\Microsoft VS Code\bin\code.cmd"
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot-chat
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.python
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.vscode-pylance
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.debugpy
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension MS-CEINTL.vscode-language-pack-ja
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension saoudrizwan.claude-dev
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension rust-lang.rust-analyzer
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension tamasfe.even-better-toml
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension anthropic.claude-code
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension almenon.arepl
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --list-extensions --show-versions

REM settings.json を作成(自動更新オフ、Python、Claude Code 設定)
if not exist "%APPDATA%\Code\User" mkdir "%APPDATA%\Code\User"
python -c "import json,os;data={'update.mode':'none','update.enableWindowsBackgroundUpdates':False,'extensions.autoUpdate':False,'python.defaultInterpreterPath':r'C:\Program Files\Python312\python.exe','claudeCode.environmentVariables':[{'name':'ANTHROPIC_API_KEY','value':'not-needed'},{'name':'ANTHROPIC_AUTH_TOKEN','value':'ollama'},{'name':'ANTHROPIC_BASE_URL','value':'http://localhost:11434'},{'name':'ANTHROPIC_MODEL','value':'glm-4.7-flash'},{'name':'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC','value':'1'}]};p=os.path.join(os.environ['APPDATA'],'Code','User','settings.json');open(p,'w',encoding='utf-8').write(json.dumps(data,indent=4));print('Done:',p)"

REM 自動更新の抑止ポリシー(settings.json に加えて、レジストリ側でも明示的にオフ)
reg add "HKLM\SOFTWARE\Policies\Microsoft\VSCode" /v "UpdateMode" /t REG_SZ /d "none" /f
echo === セットアップ完了 ===

2. Python インタプリタの選択

同一マシンに複数の Python がインストールされている場合,VS Code で使用する Python 本体(インタプリタ:Python プログラムを解釈・実行するソフトウェア)を選択する必要がある.

  1. コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(Ctrl+Shift+P
  2. Python: Select Interpreter と入力する
  3. 表示される一覧から,使用する Python(例:C:\Program Files\Python312\python.exe)を選択する.

第4章 必要なライブラリのインストール

必要なライブラリをシステム領域にインストール

管理者権限コマンドプロンプトを起動する (手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。

起動したコマンドプロンプトで次を実行する。--no-user は、ユーザ領域ではなくシステム領域へインストールするためのオプションである。

pip install --no-user pillow numpy httpx

第5章 地理院標高タイルダウンローダープログラム

概要

このプログラムは、国土地理院が配信するPNG形式の標高タイルを、地図プレビューで位置を確かめながら、緯度・経度・ズームレベルを指定して取得する。取得後は標高値のプレビュー表示と、画像・メタデータの保存ができる[1][2]。

主要技術

データセットの種類

標高タイル(基盤地図情報数値標高モデル)には、作成方法の異なる複数のデータセットがある。計測精度は DEM1A > DEM5A > DEM5B > DEM5C > DEM10B の順に高い[3]。DEM10B は日本全国で整備されているが、それ以外は日本全国では整備されていない[3][4]。精度の高いデータセットほど提供範囲が限られるため、データが存在しない場合は次に精度の高いデータセットを試す。データセットごとに提供されるズームレベルが異なるため、取得できないときはズームレベルも変えて試す。

利用にあたっての注意

地理院タイルは国土地理院コンテンツ利用規約に従って利用する[6]。ウェブサイトやアプリケーション上で読み込んで利用する場合は、出典を「国土地理院」または「地理院タイル」等と記載し、地理院タイル一覧ページへのリンクを付ける[3]。

参考文献

ソースコード

"""
地理院標高タイルダウンローダープログラム

特徴技術名: 標高タイル(基盤地図情報数値標高モデル)
出典: 国土地理院. 標高タイルの詳細仕様. https://maps.gsi.go.jp/development/demtile.html

特徴機能: PNG形式による標高データの配信
RGB値に標高値をエンコードし、256x256ピクセルのタイルとして配信する。
計算式: x = R*65536 + G*256 + B
        h = x*0.01              (x < 2^23)
        h = (x - 2^24)*0.01     (x > 2^23)
        x = 2^23 すなわち (R,G,B)=(128,0,0) は無効値

学習済みモデル: なし

方式設計:
  関連利用技術:
  - tkinter: GUIフレームワーク(ウィンドウ、ボタン、入力フィールド)
  - Pillow: 画像処理ライブラリ(画像の読み込み、表示、変換)
  - NumPy: 数値計算ライブラリ(標高データの配列処理)
  - HTTPX: HTTP通信ライブラリ(タイルデータの取得)

  入力と出力:
  入力: 緯度・経度、ズームレベル、データセットの選択
  出力: 標高タイル画像の表示と保存、標高値の数値データ

  処理手順:
  1. 緯度・経度からタイル座標への変換
  2. 指定したデータセットの標高タイルをHTTPで取得
  3. PNG画像のRGB値を標高値にデコード
  4. 可視化とメタデータの表示

  前処理: 座標変換(緯度・経度からWebメルカトルのタイル座標へ)
  後処理: 標高データの統計(最小値・最大値)の算出

  追加処理: 地図プレビュー(現在位置の地図タイル表示と範囲の明示)

  調整を必要とする設定値:
  - DEFAULT_ZOOM(既定のズームレベル): 取得する標高データの解像度を決める(推奨値15)

将来方策: 指定した座標で利用できる最も精度の高いデータセットの自動選択

その他の重要事項:
- 出典明示が必要: 「国土地理院」または「地理院タイル」
- 利用規約: https://www.gsi.go.jp/kikakuchousei/kikakuchousei40182.html

前準備:
pip install --no-user pillow numpy httpx
"""

import math
import httpx
from PIL import Image, ImageTk, ImageDraw
import numpy as np
from io import BytesIO
from datetime import datetime
import tkinter as tk
from tkinter import ttk, messagebox
import threading

# 定数定義
TILE_SIZE = 256  # タイルサイズ(ピクセル)
DEFAULT_LAT = 35.6812  # 既定の緯度(東京)
DEFAULT_LON = 139.7671  # 既定の経度(東京)
DEFAULT_ZOOM = 15  # 既定のズームレベル(推奨値)
MIN_ZOOM = 10  # 最小ズームレベル
MAX_ZOOM = 18  # 最大ズームレベル
MAP_TILE_ZOOM = 8  # 地図プレビュー用ズームレベル
PREVIEW_SIZE = 256  # プレビュー表示サイズ(ピクセル)
TILE_RANGE = 4  # 地図プレビューで表示するタイル範囲(4x4)

# データセット情報
datasets = {
    'dem1a_png': '航空レーザ測量 1mメッシュ(標高精度0.3m以内)',
    'dem5a_png': '航空レーザ測量 5mメッシュ(標高精度0.3m以内)',
    'dem5b_png': '写真測量 5mメッシュ(標高精度0.7m以内)',
    'dem5c_png': '写真測量 5mメッシュ(標高精度1.4m以内)',
    'dem_png': '地形図の等高線 10mメッシュ(標高精度5m以内、日本全国)'
}

# グローバル変数
current_tile_data = None
current_tile_coords = None
current_elevation = None
current_dataset = None
japan_map = None
map_info = {}

def lat_lon_to_tile(lat, lon, zoom):
    """Webメルカトル投影による緯度経度→タイル座標変換"""
    lat_rad = math.radians(lat)
    n = 2.0 ** zoom
    x = math.floor((lon + 180.0) / 360.0 * n)
    y = math.floor((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
    # タイル座標の境界チェック
    x = max(0, min(int(n - 1), x))
    y = max(0, min(int(n - 1), y))
    return x, y

def tile_to_lat_lon(x, y, zoom):
    """タイル座標→緯度経度変換"""
    n = 2.0 ** zoom
    lon = (x + 0.5) / n * 360.0 - 180.0
    lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * (y + 0.5) / n)))
    lat = math.degrees(lat_rad)
    return lat, lon

def decode_elevation_png(image_array):
    """標高タイル(PNG形式)のRGB値を標高値[m]にデコードする"""
    R = image_array[:, :, 0].astype(np.uint32)
    G = image_array[:, :, 1].astype(np.uint32)
    B = image_array[:, :, 2].astype(np.uint32)

    # x = R*65536 + G*256 + B、u は標高分解能(0.01m)
    x = R * 65536 + G * 256 + B
    u = 0.01

    # h = x*u (x < 2^23)、h = (x - 2^24)*u (x > 2^23)
    elevation = np.where(x < 8388608,
                         x.astype(np.float64) * u,
                         (x.astype(np.float64) - 16777216) * u)

    # x = 2^23 すなわち (R,G,B)=(128,0,0) は無効値
    elevation[x == 8388608] = np.nan

    return elevation

def download_map_tiles():
    """日本地図タイルのダウンロード"""
    zoom = MAP_TILE_ZOOM
    tiles = []

    # 日本全体をカバーするタイル範囲
    japan_tiles = []
    for x in range(220, 232):
        for y in range(98, 110):
            japan_tiles.append((x, y))

    total_tiles = len(japan_tiles)

    for i, (x, y) in enumerate(japan_tiles):
        url = f'https://cyberjapandata.gsi.go.jp/xyz/std/{zoom}/{x}/{y}.png'
        response = httpx.get(url, timeout=10.0)
        tiles.append((x, y, Image.open(BytesIO(response.content))))

        status_var.set(f'地図データをダウンロード中... ({i+1}/{total_tiles})')
        root.update_idletasks()

    create_japan_map(tiles, zoom)
    status_var.set('準備完了')
    update_map_preview()

def create_japan_map(tiles, zoom):
    """日本地図の作成"""
    global japan_map, map_info
    x_coords = [t[0] for t in tiles]
    y_coords = [t[1] for t in tiles]
    min_x, max_x = min(x_coords), max(x_coords)
    min_y, max_y = min(y_coords), max(y_coords)

    width = (max_x - min_x + 1) * TILE_SIZE
    height = (max_y - min_y + 1) * TILE_SIZE

    japan_map = Image.new('RGB', (width, height), (150, 150, 150))

    for x, y, img in tiles:
        pos_x = (x - min_x) * TILE_SIZE
        pos_y = (y - min_y) * TILE_SIZE
        japan_map.paste(img, (pos_x, pos_y))

    map_info = {
        'zoom': zoom,
        'min_x': min_x,
        'max_x': max_x,
        'min_y': min_y,
        'max_y': max_y,
        'width': width,
        'height': height
    }

def move_tile(direction):
    """タイル移動処理"""
    try:
        lat = float(lat_var.get())
        lon = float(lon_var.get())
        zoom = zoom_var.get()

        x, y = lat_lon_to_tile(lat, lon, zoom)

        if direction == 'north':
            y -= 1
        elif direction == 'south':
            y += 1
        elif direction == 'east':
            x += 1
        elif direction == 'west':
            x -= 1

        # 境界チェック
        n = 2 ** zoom
        if x < 0 or x >= n or y < 0 or y >= n:
            return

        new_lat, new_lon = tile_to_lat_lon(x, y, zoom)

        lat_var.set(f'{new_lat:.6f}')
        lon_var.set(f'{new_lon:.6f}')
    except ValueError:
        # 入力途中で数値として読み取れない場合は何もしない
        pass

def update_map_preview():
    """地図プレビューの更新"""
    if not japan_map:
        return

    try:
        lat = float(lat_var.get())
        lon = float(lon_var.get())
        zoom = zoom_var.get()
    except ValueError:
        # 入力途中で数値として読み取れない場合は何もしない
        return

    current_x, current_y = lat_lon_to_tile(lat, lon, zoom)

    # スケール計算
    scale = 2 ** (zoom - MAP_TILE_ZOOM)
    map_center_x = current_x / scale
    map_center_y = current_y / scale

    pixel_x = (map_center_x - map_info['min_x']) * TILE_SIZE
    pixel_y = (map_center_y - map_info['min_y']) * TILE_SIZE

    if (pixel_x < 0 or pixel_x > map_info['width'] or
        pixel_y < 0 or pixel_y > map_info['height']):
        map_canvas.delete('all')
        map_canvas.create_text(PREVIEW_SIZE // 2, PREVIEW_SIZE // 2, text='範囲外', fill='red')
        return

    view_size = TILE_SIZE * TILE_RANGE / scale
    half_size = view_size / 2

    left = max(0, int(pixel_x - half_size))
    top = max(0, int(pixel_y - half_size))
    right = min(map_info['width'], int(pixel_x + half_size))
    bottom = min(map_info['height'], int(pixel_y + half_size))

    if right > left and bottom > top:
        cropped = japan_map.crop((left, top, right, bottom))
        preview = cropped.resize((PREVIEW_SIZE, PREVIEW_SIZE), Image.Resampling.LANCZOS)

        draw_img = preview.copy()
        draw = ImageDraw.Draw(draw_img)

        tile_size_on_preview = PREVIEW_SIZE / TILE_RANGE
        center_x = PREVIEW_SIZE // 2
        center_y = PREVIEW_SIZE // 2
        half_tile = tile_size_on_preview / 2

        draw.rectangle([
            center_x - half_tile,
            center_y - half_tile,
            center_x + half_tile,
            center_y + half_tile
        ], outline='red', width=2)

        draw.line([center_x - 10, center_y, center_x + 10, center_y], fill='blue', width=2)
        draw.line([center_x, center_y - 10, center_x, center_y + 10], fill='blue', width=2)

        map_photo = ImageTk.PhotoImage(draw_img)
        map_canvas.delete('all')
        map_canvas.create_image(center_x, center_y, image=map_photo)
        map_canvas.image = map_photo

        map_canvas.create_text(center_x, 240,
            text=f'Tile: ({current_x}, {current_y}) Z{zoom}',
            fill='black', anchor='center')

def update_zoom_label(value):
    """ズームレベルの整数化とラベル・地図プレビューの更新"""
    zoom = int(float(value))
    zoom_var.set(zoom)
    zoom_label.config(text=f'{zoom} (推奨: 15)')
    update_map_preview()

def download_tile():
    """標高タイルのダウンロード"""
    global current_tile_data, current_tile_coords, current_elevation, current_dataset
    try:
        lat = float(lat_var.get())
        lon = float(lon_var.get())
    except ValueError:
        status_var.set('エラー: 入力値が不正です')
        info_label.config(text='エラー: 緯度・経度は数値で入力してください')
        return

    zoom = zoom_var.get()
    dataset = dataset_var.get()

    x, y = lat_lon_to_tile(lat, lon, zoom)
    current_tile_coords = (x, y, zoom)

    url = f'https://cyberjapandata.gsi.go.jp/xyz/{dataset}/{zoom}/{x}/{y}.png'

    status_var.set(f'ダウンロード中: {dataset} タイル({x}, {y})...')
    root.update()

    response = httpx.get(url, timeout=30.0)

    if response.status_code == 200:
        current_tile_data = response.content
        current_dataset = dataset

        img = Image.open(BytesIO(current_tile_data))
        current_elevation = decode_elevation_png(np.array(img))

        display_image(img)
        update_info(lat, lon, zoom, x, y, dataset)

        save_button.config(state=tk.NORMAL)
        status_var.set('ダウンロード完了')
    elif response.status_code == 404:
        status_var.set('エラー: データなし')
        info_label.config(text=f'データなし: 指定した位置に{dataset}のデータが存在しません\nタイル座標: ({x}, {y}), ズーム: {zoom}\n別のデータセットまたはズームレベルを試すこと')
    else:
        status_var.set(f'エラー: HTTP {response.status_code}')
        info_label.config(text=f'ダウンロード失敗: HTTP {response.status_code}\nタイル座標: ({x}, {y}), ズーム: {zoom}')

def display_image(pil_image):
    """標高データの可視化表示"""
    elevation = decode_elevation_png(np.array(pil_image))

    valid_elevation = elevation[~np.isnan(elevation)]
    if len(valid_elevation) > 0:
        min_elev = np.min(valid_elevation)
        max_elev = np.max(valid_elevation)

        if max_elev > min_elev:
            normalized = (elevation - min_elev) / (max_elev - min_elev) * 255
        else:
            normalized = np.full_like(elevation, 127)

        normalized[np.isnan(normalized)] = 0
        vis_image = Image.fromarray(normalized.astype(np.uint8), mode='L')
        photo = ImageTk.PhotoImage(vis_image)
    else:
        photo = ImageTk.PhotoImage(pil_image)

    canvas.delete('all')
    canvas.create_image(TILE_SIZE//2, TILE_SIZE//2, image=photo)
    canvas.image = photo

def update_info(lat, lon, zoom, x, y, dataset):
    """情報表示の更新"""
    valid_elevation = current_elevation[~np.isnan(current_elevation)]
    if len(valid_elevation) > 0:
        elevation_text = f"""標高範囲:
最小: {np.min(valid_elevation):.1f}m
最大: {np.max(valid_elevation):.1f}m"""
    else:
        elevation_text = '標高範囲: データなし'

    info_label.config(text=f"""座標: {lat:.4f}, {lon:.4f}
ズームレベル: {zoom}
タイル座標: ({x}, {y})
データセット: {dataset}

{elevation_text}

データソース: 国土地理院
標高タイル""")

def save_tile():
    """タイルデータの保存"""
    x, y, zoom = current_tile_coords
    dataset = current_dataset.replace('_png', '')
    filename = f'tile_{dataset}_{zoom}_{x}_{y}.png'

    with open(filename, 'wb') as f:
        f.write(current_tile_data)

    save_metadata(filename, x, y, zoom)

    status_var.set(f'保存完了: {filename}')
    messagebox.showinfo('保存完了', f'ファイルを保存しました:\n{filename}')

def save_metadata(image_filename, x, y, zoom):
    """メタデータファイルの保存"""
    metadata_filename = image_filename.replace('.png', '_metadata.txt')
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')

    lat = float(lat_var.get())
    lon = float(lon_var.get())

    with open(metadata_filename, 'w', encoding='utf-8') as f:
        f.write('=== 標高タイル メタデータ ===\n')
        f.write(f'保存日時: {timestamp}\n')
        f.write('データソース: 国土地理院\n')
        f.write(f'データセット: {current_dataset}\n')
        f.write(f'元座標: {lat}, {lon}\n')
        f.write(f'タイル座標: ({x}, {y})\n')
        f.write(f'ズームレベル: {zoom}\n')

        valid_elevation = current_elevation[~np.isnan(current_elevation)]
        if len(valid_elevation) > 0:
            f.write(f'標高範囲: {np.min(valid_elevation):.1f}m - {np.max(valid_elevation):.1f}m\n')

        f.write('\n【利用時の出典明示】\n')
        f.write('「国土地理院」または「地理院タイル」\n')
        f.write('\n利用規約: https://www.gsi.go.jp/kikakuchousei/kikakuchousei40182.html\n')

def show_license_info():
    """利用条件の表示"""
    license_text = """標高タイル(地理院タイル)の利用条件

・出典明示: 必須(「国土地理院」または「地理院タイル」)
・出典には地理院タイル一覧ページへのリンクを付ける
  https://maps.gsi.go.jp/development/ichiran.html

詳細: https://www.gsi.go.jp/kikakuchousei/kikakuchousei40182.html

この条件に同意して利用すること。"""

    messagebox.showinfo('利用条件', license_text)

# プログラム開始時のガイダンス表示
print("=== 地理院標高タイルダウンローダー ===")
print("概要: 国土地理院の標高タイルをダウンロードし、標高データを可視化するプログラムです")
print("\n操作方法:")
print("1. 緯度・経度を入力(既定: 東京)")
print("2. ズームレベルを選択(推奨: 15)")
print("3. データセットを選択(高精度順: dem1a > dem5a > dem5b > dem5c > dem)")
print("4. 'ダウンロード開始'ボタンをクリック")
print("5. 結果を確認後、'保存'ボタンで画像とメタデータを保存")
print("\n注意事項:")
print("・十字ボタンで隣接タイルに移動可能")
print("・地図プレビューで現在位置を確認可能")
print("・保存時は出典明示が必要です(国土地理院)")
print("・利用規約: https://www.gsi.go.jp/kikakuchousei/kikakuchousei40182.html")
print("=====================================\n")

# GUI作成
root = tk.Tk()
root.title('地理院標高タイルダウンローダー')
root.geometry('800x800')

main_frame = ttk.Frame(root, padding='5')
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))

# 入力部分
input_frame = ttk.LabelFrame(main_frame, text='座標設定', padding='5')
input_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=2)

# 緯度
ttk.Label(input_frame, text='緯度:').grid(row=0, column=0, sticky=tk.W, padx=5)
lat_var = tk.StringVar(value=str(DEFAULT_LAT))
lat_var.trace_add('write', lambda *args: update_map_preview())
lat_entry = ttk.Entry(input_frame, textvariable=lat_var, width=15)
lat_entry.grid(row=0, column=1, padx=5)

# 経度
ttk.Label(input_frame, text='経度:').grid(row=0, column=2, sticky=tk.W, padx=5)
lon_var = tk.StringVar(value=str(DEFAULT_LON))
lon_var.trace_add('write', lambda *args: update_map_preview())
lon_entry = ttk.Entry(input_frame, textvariable=lon_var, width=15)
lon_entry.grid(row=0, column=3, padx=5)

# 移動ボタン(十字配置)
move_frame = ttk.Frame(input_frame)
move_frame.grid(row=0, column=4, rowspan=2, padx=20)

ttk.Button(move_frame, text='↑', width=3, command=lambda: move_tile('north')).grid(row=0, column=1)
ttk.Button(move_frame, text='←', width=3, command=lambda: move_tile('west')).grid(row=1, column=0)
ttk.Button(move_frame, text='→', width=3, command=lambda: move_tile('east')).grid(row=1, column=2)
ttk.Button(move_frame, text='↓', width=3, command=lambda: move_tile('south')).grid(row=2, column=1)

# ズームレベル(スライダー)
zoom_frame = ttk.Frame(input_frame)
zoom_frame.grid(row=1, column=0, columnspan=4, sticky=(tk.W, tk.E), pady=5)

ttk.Label(zoom_frame, text='ズームレベル:').grid(row=0, column=0, sticky=tk.W)
zoom_var = tk.IntVar(value=DEFAULT_ZOOM)
zoom_slider = ttk.Scale(
    zoom_frame,
    from_=MIN_ZOOM,
    to=MAX_ZOOM,
    orient=tk.HORIZONTAL,
    variable=zoom_var,
    command=update_zoom_label
)
zoom_slider.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=10)
zoom_frame.columnconfigure(1, weight=1)

zoom_label = ttk.Label(zoom_frame, text=f'{DEFAULT_ZOOM} (推奨: 15)')
zoom_label.grid(row=0, column=2)

# データセット選択
dataset_frame = ttk.LabelFrame(main_frame, text='データセット選択', padding='5')
dataset_frame.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=2)

ttk.Label(dataset_frame, text='※より高精度なもの(dem1a > dem5a > dem5b > dem5c > dem)を優先',
         foreground='blue').grid(row=0, column=0, columnspan=2, sticky=tk.W, pady=(0, 5))

dataset_var = tk.StringVar(value='dem5a_png')
for i, (dataset, description) in enumerate(datasets.items()):
    ttk.Radiobutton(
        dataset_frame,
        text=f'{dataset}: {description}',
        variable=dataset_var,
        value=dataset
    ).grid(row=i+1, column=0, sticky=tk.W, pady=2)

# ダウンロードボタン
download_button = ttk.Button(
    main_frame,
    text='ダウンロード開始',
    command=download_tile
)
download_button.grid(row=2, column=0, columnspan=2, pady=5)

# 表示部分(標高タイルと地図を横並び)
display_frame = ttk.Frame(main_frame)
display_frame.grid(row=3, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=2)

# 標高タイル表示
elevation_frame = ttk.LabelFrame(display_frame, text='ダウンロードしたデータ', padding='5')
elevation_frame.grid(row=0, column=0, padx=2)

canvas = tk.Canvas(elevation_frame, width=TILE_SIZE, height=TILE_SIZE, bg='gray')
canvas.grid(row=0, column=0, padx=5, pady=5)

# 地図プレビュー
map_frame = ttk.LabelFrame(display_frame, text='地図プレビュー', padding='5')
map_frame.grid(row=0, column=1, padx=2)

map_canvas = tk.Canvas(map_frame, width=PREVIEW_SIZE, height=PREVIEW_SIZE, bg='lightgray')
map_canvas.grid(row=0, column=0, padx=5, pady=5)

map_canvas.create_text(PREVIEW_SIZE//2, PREVIEW_SIZE//2, text='地図データ読み込み中...', fill='gray', tags='loading')

# 情報表示
info_frame = ttk.Frame(display_frame)
info_frame.grid(row=0, column=2, sticky=(tk.W, tk.E, tk.N), padx=5)

info_label = ttk.Label(info_frame, text='データ未取得', justify=tk.LEFT, font=('', 8))
info_label.grid(row=0, column=0, sticky=(tk.W, tk.N))

# 保存ボタン
save_button = ttk.Button(
    main_frame,
    text='保存',
    command=save_tile,
    state=tk.DISABLED
)
save_button.grid(row=4, column=0, columnspan=2, pady=5)

# ステータスバー
status_var = tk.StringVar(value='準備完了')
status_bar = ttk.Label(main_frame, textvariable=status_var, relief=tk.SUNKEN)
status_bar.grid(row=5, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=2)

# 初期処理
show_license_info()

# 地図タイルをバックグラウンドでダウンロード
status_var.set('地図データをダウンロード中...')
threading.Thread(target=download_map_tiles, daemon=True).start()

# アプリケーション実行
root.mainloop()