オリジナル地図データでアプリを作ろう

## はじめに
この記事は「NTTテクノクロス Advent Calendar 2024（シリーズ2）」の18日目の記事です。

こんにちは。NTT テクノクロスの板垣です。社内ではGIS関連技術のプロジェクトに携わることが多く、これまで様々な地図アプリの開発に従事してきました。
この記事では、Google Mapなどを使わずにオリジナルの地図データを作成して、Androidアプリに組み込むまでの手順を解説します。

## なぜオリジナル地図データを作成するのか

Google Mapはとても便利であり、私自身もこれまで多くのプロダクトで利用してきました。
ただ、Google Mapを利用する場合にいくつかの課題があります。

1. 利用者が多いためアプリケーションのオリジナリティを出しづらい
2. 私有地などの場合に詳細な地図になっていない
3. アプリ利用者数が多い場合に費用が高額になる

特に自治体で地域のおすすめスポットを紹介したり、イベントを開催する際に会場を案内するような地図を提供したい方にはオリジナルデータを作成して地図アプリケーションを作成してみて頂きたいです。

## オリジナル地図データでアプリを作るまでの流れ

今回は以下のステップで作成をしていきます。
GISの知識が無い方向けに基礎的な説明を交えながら進めていきます。

[オリジナルのラスタデータを作成する](#オリジナルのラスタデータを作成する)
[ラスタデータから地図タイル(XYZタイル)を作成する](#ラスタデータから地図タイルxyzタイルを作成する)
[MapLibreを使ってオリジナル地図タイルをAndroidアプリに組み込む](#maplibreを使ってオリジナル地図タイルをandroidアプリに組み込む)

<a id="anchor1"></a>
## オリジナルのラスタデータを作成する

位置情報を取り扱うデータにはベクトル(ベクタ)とラスタの2つがあります。
ここではラスタデータについて解説します。

### ラスタデータとは

ラスタデータは、ピクセル情報をグリッドの形式で表現した地図データです。通常、画像ファイルの形式で保存されます。以下に主要なラスタ地図データのフォーマットを表形式で示します。

| フォーマット名      | 特徴                                                                 | 主な用途                                |
|------------------|----------------------------------------------------------------------|-------------------------------------|
| **GeoTIFF**      | 地理参照情報を含むTIFF形式。高精度な座標情報をメタデータとして埋め込める。          | GISソフトウェアで広く利用                 |
| **PNG/JPEG**     | 一般的な画像フォーマット。標準では地理参照情報を含まないため、ワールドファイル等の補足データが必要。 | 基本的な画像表示や軽量用途、Web表示など        |
| **ECW**          | 高い圧縮率と高速な表示が可能な商用圧縮ラスタ形式。大規模な衛星画像や航空写真で使用される。       | 衛星画像、航空写真、大規模データセット          |

なお、今回は入手の容易さ、地図の編集のしやすさからPNG形式のラスタデータを利用して説明します。

### png形式のラスタデータ

PNG形式のラスタデータはPNGの画像ファイル(.png)と座標情報が記載されたワールドファイル(.pgw)の2つで構成されます。PNGファイルはわかると思うのですがワールドファイルは馴染みがない方も多いと思いますので、ファイルの中身を見てみましょう。

```
0.000009186000000
0
0
0.000007486000000
135.3823772
34.6513086
```
最後の2つは緯度経度と思われる値ですが、上部４つの値は何でしょう？
実はワールドファイルはアフィン変換という計算に利用するためのデータファイルになっています。
イメージしやすくするために、地図上に画像ファイルを配置することを考えてください。
最後の２つの値が画像の左上の緯度経度になっており、まずこの位置に画像を配置します。
ただ、地図には縮尺の概念があるため、単純に緯度経度の位置に配置するだけはなく画像のサイズを変化させる必要があります。

この画像処理を行うためにアフィン変換を行うわけですが、その際にワールドファイルを利用します。以下は画像ファイルの左上/右下の緯度経度、画像のXY方向のピクセル数を指定することでワールドファイルを出力するpythonのコードになります。

```python
import argparse

def create_world_file(top_left_lat, top_left_lon, bottom_right_lat, bottom_right_lon, pixel_x, pixel_y, output_file):
    """
    ワールドファイルを作成するプログラム。

    :param top_left_lat: 左上ピクセル「中心点」の緯度
    :param top_left_lon: 左上ピクセル「中心点」の経度
    :param bottom_right_lat: 右下ピクセル「中心点」の緯度
    :param bottom_right_lon: 右下ピクセル「中心点」の経度
    :param pixel_x: 画像ファイルのx方向のピクセル数
    :param pixel_y: 画像ファイルのy方向のピクセル数
    :param output_file: 出力するワールドファイルの名前
    """
    # 経度方向の1ピクセルあたりの解像度を計算
    resolution_lon = (bottom_right_lon - top_left_lon) / pixel_x

    # 緯度方向の1ピクセルあたりの解像度を計算（注意: 緯度は北から南に減少するので負の値）
    resolution_lat = (bottom_right_lat - top_left_lat) / pixel_y

    # ワールドファイルの内容を作成
    world_file_content = [
        f"{resolution_lon:.15f}\n",  # 経度方向の解像度
        "0\n",                        # 回転情報（X方向、通常は0）
        "0\n",                        # 回転情報（Y方向、通常は0）
        f"{resolution_lat:.15f}\n",   # 緯度方向の解像度
        f"{top_left_lon}\n",          # 左上の経度（原点X）
        f"{top_left_lat}\n"           # 左上の緯度（原点Y）
    ]

    # ワールドファイルを作成して書き込む
    with open(output_file, "w") as file:
        file.writelines(world_file_content)

    print(f"ワールドファイル '{output_file}' が作成されました。")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="ワールドファイルを作成するプログラム")
    parser.add_argument("--top_left_lat", type=float, required=True, help="左上の緯度")
    parser.add_argument("--top_left_lon", type=float, required=True, help="左上の経度")
    parser.add_argument("--bottom_right_lat", type=float, required=True, help="右下の緯度")
    parser.add_argument("--bottom_right_lon", type=float, required=True, help="右下の経度")
    parser.add_argument("--pixel_x", type=int, required=True, help="x方向のピクセル数")
    parser.add_argument("--pixel_y", type=int, required=True, help="y方向のピクセル数")
    parser.add_argument("--output_file", type=str, required=True, help="出力するワールドファイルの名前")

    args = parser.parse_args()

    create_world_file(
        top_left_lat=args.top_left_lat,
        top_left_lon=args.top_left_lon,
        bottom_right_lat=args.bottom_right_lat,
        bottom_right_lon=args.bottom_right_lon,
        pixel_x=args.pixel_x,
        pixel_y=args.pixel_y,
        output_file=args.output_file
    )
```

そして、上記のワールドファイルと一致する解像度の画像ファイルであれば地図データとして利用することが出来ます(拡張子を含まないファイル名を一致させる必要があります)。
ただ、自分で独自の地図画像を作成する場合、画像ファイルの左上/右下の緯度経度を調べる必要があり、やや敷居が高いと思います。そこで、ここでは国土地理院の地理院地図Vectorを利用します。

![スクリーンショット 2024-12-16 0.53.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318696/c2aa0ef3-942e-965b-f0aa-44de93e2d3f6.png)


地理院地図Vectorでは左のメニューで地図の見栄えや、表示するデータを選択可能(目のアイコンを押すことで切り替え)で、右上のメニューから「画像として保存」を選択することで地図画像とワールドファイルをダウンロードすることが出来ます。

ダウンロードした画像ファイルをIllustratorなどのレイヤー管理の出来る画像編集ソフトに取り込んで、地図画像の上からイラスト地図を描き、ダウンロードした画像ファイルのレイヤーを削除することでオリジナルの地図を作成できます。
※ただし、地図にも著作権がありますので地理院地図に関わらず注意が必要です。地理院地図の場合、今回のような地図データを加工して利用する場合は「・地理院タイル （ベクトルタイル淡色地図）を加工して作成」のような表記を行う必要があります。詳細は[こちら](https://www.gsi.go.jp/kikakuchousei/kikakuchousei40182.html)をご確認ください。

今回は地図の色合いを変えて、おすすめの公園の写真を追加したpngファイルに作成してみました。
なお、ZOOMすると画像が荒く感じると思います。これは元画像を元にリサンプリングして作成されているためです。より綺麗に作成されたい場合は<font color="red">高解像度な画像を用意しZOOMレベルごとにタイルを作成すること</font>をおすすめします。

![gsi20241216011021412.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318696/e9c28d60-6b79-c0d2-0c4f-ca997bcd1391.png)


## ラスタデータから地図タイル(XYZタイル)を作成する

### 地図タイル(XYZ Tiles)とは
前章で作成したラスタデータは１枚の画像のため、例えば日本全体の地図を表示したい場合は膨大なデータ量になってしまいます。そこで、地図データをある程度分割して配信する仕組みが作られてきました。その中でもよく利用されている方式としてXYZタイルがあります。

XYZタイルは地球地図全体を一枚の正方形タイルで表現したものを「ズームレベル0」、一枚の正方形タイルの辺の長さを2倍にして縦横それぞれ2分の1に分割したものを「ズームレベル1」と、以降ズームレベルが上がる事に分割数を増やして作成したタイルを位置に応じてデータ配信をする方式になっております。各タイルはX座標、Y座標、ズーム(Zoom)の3つの値から一意な地図タイルを表示することが出来ます。

![tileNum.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318696/c6e1e6d2-a97a-b195-a7f4-c22beb61c656.png)

※国土地理院の「[地理院タイルについて](https://maps.gsi.go.jp/development/siyou.html)」のページより転載

### GDALを用いた地図タイルの変換

GDAL(Geospatial Data Abstraction Library) は、OSGeo財団がX/MITライセンスにより提供している、ラスターおよびベクター地理空間情報データフォーマットのための変換用ライブラリです。
GDALを利用すると地理空間情報を変更したり、データ形式変更などGISデータに関する様々な変換を行うことが出来ます。以下はmacOSでのインストール方法と利用方法になります。

#### 1. Homebrewを使ってGDALをインストール

```bash
brew install gdal
```

インストール後、以下のコマンドで動作確認します

```bash
gdalinfo --version
```
出力例：
```yaml
GDAL 3.9.2, released 2024/08/13
```

#### 2. 元データについて
前の章で作成したpngファイルとワールドファイルを両方同じフォルダかつ同名にして保存してください。

```
input.png ：ラスタ画像
input.pgw ：PNGワールドファイル
```

#### 3. 投影法の確認と形式変換
XYZタイルを作る場合は、通常 Webメルカトル (EPSG:3857) 投影に変換します。
また後述するタイル生成においてGeoTiff形式を利用するため GDALのgdalwarpで変換します。

```bash
gdalwarp -t_srs EPSG:3857 input.png input_3857.tif
```

#### 4. タイル生成 (gdal2tiles.py)
GDALには gdal2tiles.py というスクリプトがあり、ラスターデータからXYZタイルを生成できます。

基本的なコマンド例：

```bash
 gdal2tiles.py -z 15-18 --xyz input_3857.tif output_tile
```
オプション説明：

-z 15-18 : ズームレベル15から18までのタイルを作成。必要に応じて変更可能。
--xyz : タイルをxzy形式で出力。
tiles_output_directory : 出力先ディレクトリ。存在しなければ自動で作成。

#### 5. 結果確認
生成されたtiles_output_directory以下に、ズームレベル別のディレクトリ構造でタイルが作られていることを確認します。また、openlayers.htmlやleaflet.htmlなどのプレビュー用HTMLファイルも生成されるため、正しく生成されているか簡易的に確認できます。

![スクリーンショット 2024-12-22 12.08.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318696/f421bee1-8721-d100-4b55-d510a9dfa93f.png)


## MapLibreを使ってオリジナル地図タイルをAndroidアプリに組み込む

### MapLibreとは
MapLibreはオープンソースのライブラリで元はMapboxからフォークして開発されました。WebだけではなくAndroid / iOSのSDKも開発されています。

### MapLibreの実装
#### 1. MapLibreの依存関係を追加
まず、空のプロジェクトを作成してbuild.gradleにMapLibreの依存関係を追加します。

```groovy:app/build.gradle
dependencies {
    implementation 'org.maplibre.gl:android-sdk:11.5.1'
}
```
注意: 最新バージョンはMapLibre Android SDKの公式リポジトリで確認してください。

#### 2. レイアウトファイルの作成
レイアウトファイルにMapViewを配置します。

```xml:activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <org.maplibre.android.maps.MapView
        android:id="@+id/mapView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>
```

#### 3. 地図タイルの配置
assets配下に先ほど作成した地図タイルを配置します。ただこのままではタイルを参照できないためlocal_style.jsonというファイルをassets配下に作成してください。

```json:local_style.json
{
    "version": 8,
    "sources": {
        "raster-tiles": {
            "type": "raster",
            "tiles": [
                "asset://MapTiles/{z}/{x}/{y}.png"
            ],
            "tileSize": 256
        }
    },
    "layers": [
        {
            "id": "simple-tiles",
            "type": "raster",
            "source": "raster-tiles",
            "minzoom": 15,
            "maxzoom": 18
        }
    ]
}
```

#### 4. アクティビティクラスの作成
アクティビティクラスでMapViewを初期化し、local_style.jsonからスタイルを読み込みます。
asset://local_style.jsonというスキームで、アセット内のファイルを参照することができます。
CameraPositionのLatLonはご自身の環境に合わせて変更をお願いします。

```Kotlin:MainActivity.kt
package com.example.originalmapforMapLibre

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.MapLibre
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.Style
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.geometry.LatLng

class MainActivity : AppCompatActivity() {

    private lateinit var mapView: MapView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        MapLibre.getInstance(this)
        setContentView(R.layout.activity_main)

        mapView = findViewById(R.id.mapView)
        mapView.onCreate(savedInstanceState)

        mapView.getMapAsync { map ->
            val cameraPosition = CameraPosition.Builder()
                .target(LatLng(35.421277925278744, 139.64986157546147))
                .zoom(15.0)
                .build()
            map.cameraPosition = cameraPosition

            map.setStyle(Style.Builder().fromUri("asset://local_style.json")) {
                // スタイルロード後の処理
            }
        }
    }

    override fun onStart() {
        super.onStart()
        mapView.onStart()
    }

    override fun onResume() {
        super.onResume()
        mapView.onResume()
    }

    override fun onPause() {
        super.onPause()
        mapView.onPause()
    }

    override fun onStop() {
        super.onStop()
        mapView.onStop()
    }

    override fun onDestroy() {
        super.onDestroy()
        mapView.onDestroy()
    }

    override fun onLowMemory() {
        super.onLowMemory()
        mapView.onLowMemory()
    }
}
```

#### 5. マニフェストファイルの修正
空のプロジェクトから作成している場合、マニフェストファイルにMainActivityを追加してください。

```xml:Manifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.originalmapforMapLibre">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="OriginalMapForMapLibre"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.OriginalMapForMapLibre"
        tools:targetApi="31">

        <activity android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

    </application>
</manifest>
```

#### 6.その他
私の環境ではAndroidXに移行しているためgradle.propertiesに以下を追加しています。

```properties:gradle.properties
android.useAndroidX=true
android.enableJetifier=true
```

#### ビルド実行
実際にアプリを動作させた場合の結果は以下の通りです。他にもMapLibreの機能を使えばPINを立てたり、ルート表示を行うことも可能です。

![maplibre2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318696/96c94ea7-632c-d144-3bbd-b21ff24a2968.gif)


#### 【補足】ビルドに成功するけど画面が表示されない場合の対処
ビルドに成功してMapLibreは呼び出されるけど画面が真っ黒になってしまう場合は以下を見返してください。
- Mapの初期表示位置を見直してください。地図タイルが無い場所を中心点にしてしまい表示ができていない可能性があります。
- 地図タイルの出力を見直してください。gdal2tiles.pyに--xyzを付与しないとXYZ形式タイルになりません。

## 最後に

今回はオリジナル地図データの作成から地図タイルの製作、そしてアプリ開発までの流れを解説しました。今回はスマートフォンアプリでしたが、作成した地図はWebでも使用可能ですのでぜひ様々なアプリケーションを作成してみてください。なお、前述したように地図には著作権がありますので改造して利用される場合は利用ルールをよく確認してご利用ください。

明日は [@CorydorasNomu](https://qiita.com/CorydorasNomu)さんの「PlantUMLで設計しよう」です。
引き続き、NTTテクノクロスアドベントカレンダーをお楽しみください。