国土地理院DEM PNG to OBJ変換ツール(ソースコードと実行結果)

Python開発環境,ライブラリ類

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

Python 3.12 のインストール

インストール済みの場合は実行不要。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要である。

REM Python をシステム領域にインストール
winget install --scope machine --id Python.Python.3.12 -e --silent
REM Python のパス設定
set "PYTHON_PATH=C:\Program Files\Python312"
set "PYTHON_SCRIPTS_PATH=C:\Program Files\Python312\Scripts"
echo "%PATH%" | find /i "%PYTHON_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_PATH%" /M >nul
echo "%PATH%" | find /i "%PYTHON_SCRIPTS_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_SCRIPTS_PATH%" /M >nul

関連する外部ページ

Python の公式ページ: https://www.python.org/

AI エディタ Windsurf のインストール

Pythonプログラムの編集・実行には、AI エディタの利用を推奨する。ここでは,Windsurfのインストールを説明する。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行して、Windsurfをシステム全体にインストールする。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要となる。

winget install --scope machine Codeium.Windsurf -e --silent

関連する外部ページ

Windsurf の公式ページ: https://windsurf.com/

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

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


pip install numpy pillow matplotlib trimesh pymeshlab scipy japanize-matplotlib

国土地理院DEM PNG to OBJ変換ツールプログラム

ソースコード


"""
プログラム名: 国土地理院DEM PNG to OBJ変換ツールプログラム
特徴技術名: 国土地理院標高タイル技術
出典: 国土地理院. (2024). 標高タイルの仕様. https://maps.gsi.go.jp/development/demtile.html
特徴機能: RGB値による標高エンコーディング - PNG画像のRGB値から標高値を0.01m単位で正確にデコード
学習済みモデル: なし
方式設計:
  - 関連利用技術:
    - Delaunay三角形分割(2D点群から三角形メッシュを生成)
    - Quadric Error Metrics(メッシュの幾何学的誤差を最小化する簡略化)
    - PyMeshLab(メッシュ処理機能を提供)
  - 入力と出力: 入力: PNGファイル(国土地理院DEM形式)、出力: OBJファイル(3Dメッシュ)
  - 処理手順: 1.PNG読込 2.RGB値から標高デコード 3.点群生成 4.Delaunay三角形分割 5.QEM簡略化 6.OBJ出力
  - 前処理、後処理: 前処理: 無効値(RGB=128,0,0)のマスク処理、後処理: メッシュの境界保持・トポロジー保持
  - 追加処理: 地形特徴に基づく適応的簡略化 - 勾配、曲率、粗さから重要度を計算し、地形特徴を保持
  - 調整を必要とする設定値: 簡略化率(1-100%)- メッシュの詳細度を制御
将来方策: 地形特徴の重み付けパラメータの自動調整
その他の重要事項: 大規模DEMファイルの処理にはメモリ制限あり
前準備: pip install numpy pillow matplotlib trimesh pymeshlab scipy japanize-matplotlib
"""

import tkinter as tk
from tkinter import filedialog, messagebox, ttk
import numpy as np
from PIL import Image
import matplotlib
matplotlib.use('TkAgg')
import matplotlib.pyplot as plt
import japanize_matplotlib
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import trimesh
import pymeshlab
from scipy.spatial import Delaunay
import os

# 設定値
WINDOW_WIDTH = 600  # ウィンドウ幅
WINDOW_HEIGHT = 600  # ウィンドウ高さ
DEFAULT_SIMPLIFICATION = 10  # デフォルト簡略化率(%)
PREVIEW_SIZE = 3  # プレビュー図のサイズ(インチ)
PREVIEW_DPI = 100  # プレビュー図のDPI
INVALID_R = 128  # 無効値のR値
INVALID_G = 0  # 無効値のG値
INVALID_B = 0  # 無効値のB値
QUALITY_THRESHOLD = 0.3  # QEM簡略化の品質閾値
# 地形特徴の重み
SLOPE_WEIGHT = 0.4  # 勾配の重み
CURVATURE_WEIGHT = 0.3  # 曲率の重み
ROUGHNESS_WEIGHT = 0.3  # 粗さの重み

# グローバル変数
root = None
input_file = None
elevation_data = None
file_label = None
status_text = None
save_button = None
simplification_var = None
simplification_label = None
fig = None
ax = None
canvas = None
colorbar = None


def setup_ui():
    """UIコンポーネントの設定"""
    global root, file_label, status_text, save_button, simplification_var
    global simplification_label, fig, ax, canvas

    root = tk.Tk()
    root.title('DEM PNG to OBJ Converter')
    root.geometry(f'{WINDOW_WIDTH}x{WINDOW_HEIGHT}')

    print("=== DEM PNG to OBJ変換ツール ===")
    print("概要説明:")
    print("国土地理院のDEM PNG形式ファイルを3D OBJファイルに変換します")
    print("操作方法:")
    print("1. 'ファイル選択'ボタンで国土地理院DEM PNG形式のファイルを選択")
    print("2. 簡略化率スライダーでメッシュの詳細度を調整(1-100%)")
    print("3. '保存'ボタンでOBJファイルとして出力")
    print("注意事項:")
    print("- 大容量ファイルの処理には時間がかかります")
    print("- RGB値(128,0,0)は無効値として処理されます")
    print("- 出力ファイルは入力ファイルと同じフォルダに保存されます")
    print("=====================================")

    # ファイル選択フレーム
    file_frame = ttk.Frame(root, padding='5')
    file_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))

    ttk.Label(file_frame, text='入力PNGファイル:').grid(row=0, column=0, sticky=tk.W)
    file_label = ttk.Label(file_frame, text='未選択', relief=tk.SUNKEN, width=40)
    file_label.grid(row=0, column=1, padx=5)

    ttk.Button(file_frame, text='ファイル選択', command=select_file).grid(row=0, column=2)

    # プレビューフレーム
    preview_frame = ttk.LabelFrame(root, text='標高プレビュー', padding='5')
    preview_frame.grid(row=1, column=0, padx=5, pady=5, sticky=(tk.W, tk.E, tk.N, tk.S))

    # Matplotlibの図を作成
    fig = plt.Figure(figsize=(PREVIEW_SIZE, PREVIEW_SIZE), dpi=PREVIEW_DPI)
    ax = fig.add_subplot(111)
    canvas = FigureCanvasTkAgg(fig, master=preview_frame)
    canvas.get_tk_widget().pack()

    # パラメータフレーム
    param_frame = ttk.LabelFrame(root, text='パラメータ設定', padding='5')
    param_frame.grid(row=2, column=0, padx=5, pady=5, sticky=(tk.W, tk.E))

    ttk.Label(param_frame, text='簡略化率 (%):').grid(row=0, column=0, sticky=tk.W)
    simplification_var = tk.IntVar(value=DEFAULT_SIMPLIFICATION)
    simplification_slider = ttk.Scale(
        param_frame,
        from_=1,
        to=100,
        orient=tk.HORIZONTAL,
        variable=simplification_var,
        length=300,
        command=update_slider_label
    )
    simplification_slider.grid(row=0, column=1, padx=5)

    simplification_label = ttk.Label(param_frame, text=f'{DEFAULT_SIMPLIFICATION}%')
    simplification_label.grid(row=0, column=2)

    # 保存ボタン
    save_button = ttk.Button(
        root,
        text='保存',
        command=save_mesh,
        state=tk.DISABLED
    )
    save_button.grid(row=3, column=0, pady=5)

    # ステータスフレーム
    status_frame = ttk.LabelFrame(root, text='ステータス', padding='5')
    status_frame.grid(row=4, column=0, padx=5, pady=5, sticky=(tk.W, tk.E))

    status_text = tk.Text(status_frame, height=3, width=60)
    status_text.pack()


def update_slider_label(value):
    """スライダーラベルの更新"""
    simplification_label.config(text=f'{int(float(value))}%')


def select_file():
    """ファイル選択ダイアログ"""
    global input_file

    filename = filedialog.askopenfilename(
        title='PNG ファイルを選択',
        filetypes=[('PNG files', '*.png'), ('All files', '*.*')]
    )

    if filename:
        input_file = filename
        file_label.config(text=os.path.basename(filename))
        load_and_preview()
        save_button.config(state=tk.NORMAL)


def decode_elevation(rgb_array):
    """RGB値から標高値をデコード(国土地理院仕様完全準拠版)"""
    r = rgb_array[:, :, 0].astype(np.float64)
    g = rgb_array[:, :, 1].astype(np.float64)
    b = rgb_array[:, :, 2].astype(np.float64)

    # 国土地理院の標高タイル仕様に基づく正確な計算
    # x = R*256*256 + G*256 + B
    x = r * 256 * 256 + g * 256 + b

    # 標高分解能(0.01m)
    u = 0.01

    # 標高値の計算
    elevation = np.zeros_like(x)

    # 国土地理院仕様に基づく正確な条件判定
    # 1. RGB(128,0,0)の場合: 無効値
    invalid_rgb_mask = (r == INVALID_R) & (g == INVALID_G) & (b == INVALID_B)

    # 2. x = 2^23の場合: 無効値(NA)
    invalid_exact_mask = (x == (2**23))

    # 3. x < 2^23の場合: h = x * u(正の標高値)
    positive_mask = (x < (2**23)) & (~invalid_rgb_mask)
    elevation[positive_mask] = x[positive_mask] * u

    # 4. x > 2^23かつx ≠ 2^23の場合: h = (x - 2^24) * u(負の標高値)
    negative_mask = (x > (2**23)) & (~invalid_rgb_mask) & (~invalid_exact_mask)
    elevation[negative_mask] = (x[negative_mask] - (2**24)) * u

    # 無効値の設定
    combined_invalid_mask = invalid_rgb_mask | invalid_exact_mask
    elevation[combined_invalid_mask] = np.nan

    return elevation


def load_and_preview():
    """PNGファイルを読み込んでプレビュー表示"""
    global elevation_data, colorbar

    # 画像読み込み
    img = Image.open(input_file)
    rgb_array = np.array(img)

    # 標高値デコード
    elevation_data = decode_elevation(rgb_array)

    # プレビュー表示
    ax.clear()

    # NaN値をマスク
    masked_elevation = np.ma.masked_invalid(elevation_data)

    # カラーマップで表示
    im = ax.imshow(
        masked_elevation,
        cmap='terrain',
        aspect='equal'
    )

    # カラーバー追加
    if colorbar:
        colorbar.remove()
    colorbar = fig.colorbar(im, ax=ax)
    colorbar.set_label('標高 (m)')

    ax.set_title('標高データ')
    ax.set_xlabel('X (ピクセル)')
    ax.set_ylabel('Y (ピクセル)')

    fig.tight_layout()
    canvas.draw()

    # ステータス更新
    valid_count = np.sum(~np.isnan(elevation_data))
    total_count = elevation_data.size
    min_elev = np.nanmin(elevation_data)
    max_elev = np.nanmax(elevation_data)

    status_text.delete(1.0, tk.END)
    status_text.insert(tk.END, f'ファイル読み込み完了\n')
    status_text.insert(tk.END, f'画像サイズ: {img.size[0]}x{img.size[1]}\n')
    status_text.insert(tk.END, f'有効データ: {valid_count}/{total_count} ピクセル\n')
    status_text.insert(tk.END, f'標高範囲: {min_elev:.1f}m - {max_elev:.1f}m')


def compute_terrain_features(elevation):
    """地形特徴量の計算"""
    # 有効データの存在確認
    valid_mask = ~np.isnan(elevation)
    if not np.any(valid_mask):
        return (np.zeros_like(elevation),
                np.zeros_like(elevation),
                np.zeros_like(elevation))

    # NaN値を周囲の値で補間
    elevation_filled = elevation.copy()
    if np.any(~valid_mask):
        try:
            from scipy.interpolate import griddata
            xx, yy = np.meshgrid(np.arange(elevation.shape[1]),
                                np.arange(elevation.shape[0]))
            valid_points = np.column_stack([xx[valid_mask].ravel(),
                                          yy[valid_mask].ravel()])
            valid_values = elevation[valid_mask].ravel()

            if len(valid_points) > 0:
                filled_values = griddata(valid_points, valid_values,
                                       (xx[~valid_mask], yy[~valid_mask]),
                                       method='nearest')
                elevation_filled[~valid_mask] = filled_values
            else:
                mean_val = np.nanmean(elevation)
                elevation_filled[~valid_mask] = mean_val if not np.isnan(mean_val) else 0
        except:
            mean_val = np.nanmean(elevation)
            elevation_filled[~valid_mask] = mean_val if not np.isnan(mean_val) else 0

    # 勾配計算
    grad_y, grad_x = np.gradient(elevation_filled)
    slope = np.sqrt(grad_x**2 + grad_y**2)

    # 曲率計算
    grad_xx = np.gradient(grad_x, axis=1)
    grad_yy = np.gradient(grad_y, axis=0)
    curvature = np.abs(grad_xx + grad_yy)

    # 粗さ計算
    try:
        from scipy.ndimage import gaussian_filter
        smoothed = gaussian_filter(elevation_filled, sigma=3)
        roughness = np.abs(elevation_filled - smoothed)
    except:
        kernel_size = 3
        pad_size = kernel_size // 2
        padded = np.pad(elevation_filled, pad_size, mode='edge')
        smoothed = np.zeros_like(elevation_filled)
        for i in range(elevation_filled.shape[0]):
            for j in range(elevation_filled.shape[1]):
                smoothed[i, j] = np.mean(padded[i:i+kernel_size, j:j+kernel_size])
        roughness = np.abs(elevation_filled - smoothed)

    return slope, curvature, roughness


def compute_importance_map(elevation):
    """重要度マップの計算"""
    # 地形特徴を計算
    slope, curvature, roughness = compute_terrain_features(elevation)

    # 各特徴を正規化
    slope_95th = np.nanpercentile(slope, 95)
    curvature_95th = np.nanpercentile(curvature, 95)
    roughness_95th = np.nanpercentile(roughness, 95)

    slope_norm = slope / np.maximum(slope_95th, 1e-10)
    curvature_norm = curvature / np.maximum(curvature_95th, 1e-10)
    roughness_norm = roughness / np.maximum(roughness_95th, 1e-10)

    # クリッピング
    slope_norm = np.clip(slope_norm, 0, 1)
    curvature_norm = np.clip(curvature_norm, 0, 1)
    roughness_norm = np.clip(roughness_norm, 0, 1)

    # 重要度を計算
    importance = (SLOPE_WEIGHT * slope_norm +
                 CURVATURE_WEIGHT * curvature_norm +
                 ROUGHNESS_WEIGHT * roughness_norm)

    # 0-1に正規化
    importance_range = np.nanmax(importance) - np.nanmin(importance)
    if importance_range > 1e-10:
        importance = (importance - np.nanmin(importance)) / importance_range
    else:
        importance = np.ones_like(importance) * 0.5

    return importance


def create_mesh_from_elevation():
    """標高データからメッシュを生成(品質値付き)"""
    # 有効な点のインデックスを取得
    valid_indices = np.where(~np.isnan(elevation_data))

    # 点群を生成(画像座標系をそのまま使用)
    points = np.column_stack([
        valid_indices[1],  # x座標(列インデックス)
        valid_indices[0],  # y座標(行インデックス)
        elevation_data[valid_indices]  # z座標(標高値)
    ])

    # 重要度マップを計算
    importance_map = compute_importance_map(elevation_data)

    # 有効な点の重要度を取得
    quality_values = importance_map[valid_indices]

    # Delaunay三角形分割
    points_2d = points[:, :2]
    tri = Delaunay(points_2d)

    # trimeshオブジェクトの作成
    mesh = trimesh.Trimesh(
        vertices=points,
        faces=tri.simplices,
        process=True
    )

    return mesh, quality_values


def simplify_mesh(mesh, quality_values, target_ratio):
    """メッシュの簡略化(地形特徴を考慮)"""
    ms = pymeshlab.MeshSet()

    # 品質値を持つPyMeshLabメッシュを作成
    pm_mesh = pymeshlab.Mesh(
        vertex_matrix=mesh.vertices.astype(np.float64),
        face_matrix=mesh.faces.astype(np.int32),
        v_scalar_array=quality_values.astype(np.float64).reshape(-1, 1)
    )
    ms.add_mesh(pm_mesh)

    # カスタム属性を追加して地形特徴を反映
    try:
        # z座標(標高値)をカスタム属性として追加
        ms.compute_new_custom_scalar_attribute_per_vertex(
            name="elevation",
            expr="z"
        )
    except Exception as e:
        print(f"カスタム属性の設定でエラー: {e}")
        # エラーが発生した場合はデフォルトの品質値を使用

    # 簡略化実行
    ms.apply_filter('meshing_decimation_quadric_edge_collapse',
                    targetperc=target_ratio,
                    preserveboundary=True,
                    preservenormal=True,
                    preservetopology=True,
                    optimalplacement=True,
                    planarquadric=True,
                    qualitythr=QUALITY_THRESHOLD,
                    qualityweight=True)

    # 簡略化されたメッシュを取得
    simplified_mesh = ms.current_mesh()

    return trimesh.Trimesh(
        vertices=simplified_mesh.vertex_matrix(),
        faces=simplified_mesh.face_matrix()
    )


def save_mesh():
    """メッシュ保存"""
    save_button.config(state=tk.DISABLED)

    # 出力ファイル名の生成
    base_name = os.path.splitext(os.path.basename(input_file))[0]
    output_file = os.path.join(
        os.path.dirname(input_file),
        f'{base_name}.obj'
    )

    # メッシュ生成
    status_text.delete(1.0, tk.END)
    status_text.insert(tk.END, 'メッシュ生成中...\n')
    root.update()

    mesh, quality_values = create_mesh_from_elevation()
    original_faces = len(mesh.faces)

    # 簡略化
    simplification_ratio = simplification_var.get() / 100.0
    status_text.insert(tk.END, f'簡略化実行中 ({int(simplification_ratio*100)}%)...\n')
    status_text.insert(tk.END, '地形特徴を考慮した適応的簡略化を適用中...\n')
    root.update()

    simplified_mesh = simplify_mesh(mesh, quality_values, simplification_ratio)

    # OBJファイルとして保存
    simplified_mesh.export(output_file)

    # ファイルサイズ取得
    file_size = os.path.getsize(output_file) / 1024  # KB単位

    # 完了メッセージ
    status_text.insert(tk.END, '\n保存完了\n')
    status_text.insert(tk.END, f'ファイル名: {output_file}\n')
    status_text.insert(tk.END, f'ポリゴン数: {len(simplified_mesh.faces)} (元: {original_faces})\n')
    status_text.insert(tk.END, f'ファイルサイズ: {file_size:.1f} KB')

    messagebox.showinfo('完了', '保存が完了しました')
    save_button.config(state=tk.NORMAL)


# メイン実行
if __name__ == '__main__':
    setup_ui()
    root.mainloop()