Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

iOSエンジニアのためのGIS入門 - 地図をしくみから理解し使いこなす #iOSDC

iOSエンジニアの堤です。8月22日〜24日に開催された国内最大 1 のiOSカンファレンスで登壇しました。

プロポーザルは こちら、発表スライドはこちら:

www.docswell.com

そしてつい数日前 2 にiOSDC公式チャンネルにて発表動画が公開されました:

www.youtube.com

本記事は、同発表をベースとしつつ、時間が足りずカットした内容も盛り込みつつ記事として再構成したものになります。

はじめに

GISとは

  • Geographic Information System(地理情報システム)の略
  • 地理空間情報(=位置情報+関連する情報)を扱うシステム 3

地図アプリもGISの一例

地図アプリ: 地理空間情報(=位置情報+関連する情報)を重ね合わせて可視化し、いろんな機能(検索、ナビゲーション等)をつけたシステム

Google Maps

今日のゴール

地図をしくみから理解し、iOSでさまざまな応用ができるようになる

話さないこと(スコープ外): 特定のSDKやAPIの使い方解説
(ソースコードも出てくるが、今回重視するのは概念やしくみの理解)

アジェンダ

  • GISの基礎
  • 地図をゼロから実装する
  • 応用編

GISの基礎

ラスターデータとベクトルデータ

ラスターデータ

  • 要は画像データ

「国土地理院 標準タイル」より

  • 位置情報はどう持つのか?
    • → タイルインデックスにより一意に定まる(後述)

ベクトルデータ

緯度経度の座標を使い、以下の3つの形状を表すデータ。

  • 点(Point)・・・地点
  • 線(LineString)・・・経路、路線
  • 面(Polygon)・・・領域

位置に関する属性情報も付与可能

    • 県境データ(面)に都道府県名や人口
    • 道路データ(線)に路線名や制限速度

地図タイル

巨大な地理情報データを一度に配信できない → タイル状に分割

地理院地図|地理院タイルについて」より

タイルインデックス

タイルインデックス Z/X/Y

  • Z: ズームレベル
  • X: 横方向のインデックス
  • Y: 縦方向のインデックス

→ そのタイルの領域が一意に定まる 4


提供元が違うタイルでもインデックスが同じであれば同じ領域を表す

0/0/0 : 地球全体を表す1枚のタイル


ズームレベルが1つ上がると1つのタイルが4つに分割

地理院タイル 標準地図

ラスタータイルとベクトルタイル

  • ラスタータイル: ラスターデータのタイル

    • ファイルの実体は単なる画像(256x256 or 512x512が一般的)
    • タイルインデックス自体が位置(領域)を表す
  • ベクトルタイル: ベクトルデータのタイル

    • 詳細は後述

地図をゼロから実装する

MapKitや他のSDKを「使わずに」 iOSで地図を実装してみる

作れないものは、理解できない ― リチャード・ファインマン 5

→ 作りながら地図の仕組み/GISの基礎を理解していく

最小限の地図実装を目指す

  1. ラスタータイルを使って地図を表示
  2. 現在位置を表示
  3. ズームレベルの変更

ステップ1: ラスタータイルの描画

国土地理院タイル「標準地図」を描画する

国土地理院タイル「標準地図」のURLフォーマット:

https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png

実装:

let urlStr = String(format: "https://cyberjapandata.gsi.go.jp/xyz/std/%d/%d/%d.png", z, x, y)
AsyncImage(url: URL(string: urlStr)!)


完成

  1. ラスタータイルを使って地図を表示

ステップ2: 現在位置を表示する

現在位置(=緯度経度)をラスタータイル上にマッピングする

Webメルカトル投影法 6 に基づき 経緯度 → ピクセル座標に変換

  • 緯度経度 → ピクセル座標への変換実装 7
func latlonToXY(coord: CLLocationCoordinate2D, zoom: Double) -> (Int, Int) { ... }
  • 現在位置取得 → ピクセル座標 → スクリーン座標
let (px, py) = latlonToXY(coord: location.coordinate, zoom: z) // ピクセル座標
let x = px / tileWidth
let y = py / tileWidth
print("タイルインデックス: \(z)/\(x)/\(y)")

... // → タイル内ピクセル座標 → スクリーン座標


完成

  1. ラスタータイルを使って地図を表示
  2. 現在位置を表示 (緯度経度→ピクセル座標→スクリーン座標)

ステップ3: ズーム

ズームレベルが1つ上がると4つのタイルに分割

  • ズームイン: 1つのタイル領域に4つのタイルを描画
  • ズームアウト: 4つのタイル領域に1つのタイルを描画

4つのラスタータイルを描画

VStack(spacing: 0) {
    ForEach(0..<2, id: \.self) { row in
        HStack(spacing: 0) {
            ForEach(0..<2, id: \.self) { column in
                ... // タイル描画
            }
        }
    }
}


完成

  1. ラスタータイルを使って地図を表示
  2. 現在位置を表示
  3. ズームレベルの変更

応用編

もうちょっと「自前実装ならでは」感がほしい...

「ゼロから自前実装した」感がない

ラスタータイルはあらかじめ地名などが埋め込まれているものが多い

ベクトルタイルを利用する

クライアント側で動的に描画するため、見た目をカスタマイズ可能

ベクトルタイルの描画方法

  1. ベクトルタイルを読み込む(Protocol Buffers形式のファイルをデコード)
  2. デコードしたベクトルデータを使用して、iOSで地図として描画する

ベクトルタイルの中身を見てみよう

  • データ: OpenMapTiles 8 の Kyoto のタイルセット 9
  • Viewer: Mapbox Studio の Tilesets に読み込ませて表示

OpenMapTilesのKyotoタイルセットをMapbox Studioで表示

→ 多くのレイヤーがあり、それぞれに多くのフィールドがあり、さらにその値として多数のクラスが定義されている 10

ベクトルタイルレンダラの自前実装は難しい

  • 地図のズームレベルや位置に応じて適切にスケーリングを行う必要がある
    • 投影法を加味した複雑な計算が必要
  • ほぼ全画面に大量のベクトルデータをレンダリングする必要があり、かつ滑らかに動作する必要がある
    • OpenGLやMetalを使用し、GPUを活用して描画する

→ ここからはゼロからではなくSDKを使います

ただし

  • 「SDKの使い方」ではなく
  • あくまで地図のしくみを理解していろんな応用ができるように

というのを主眼に置いて解説します

こういうのはどう実現する?

ポケモンGO

  • 地名ラベルを非表示に
  • 道路を際立たせる
  • 山や川など歩けない場所は可視化

→ ベクトルタイルの描画方法を変更したい

→ スタイルのカスタマイズ

スタイル: 地図の視覚的な外観を定義するための設定

  • ベクトルタイルのデータをどのように表示するかを定義する
  • 実態はJSON
  • 各社のスタイル仕様は互換性がない ^8

スタイルをサポートしているiOS SDK

  • Mapbox Maps Native SDK for iOS
  • MapLibre Native SDK for iOS
    • Mapboxから枝分かれしたOSS 11

どちらでも目的は果たせるが、今回はMapbox SDKを選択

スタイルのカスタマイズ方法

  • GUIエディタを利用(Mapbox Studio, MapTiler Cloud, Maputnik)
  • クライアントサイドでの動的なスタイル変更

Mapbox Studioでスタイルをカスタマイズする

  • 地名ラベルを非表示に
  • 道路を際立たせる
  • 山や川など歩けない場所は可視化

(詳細手順はツールの使い方解説になるため割愛)

Mapbox Studioでスタイルをカスタマイズ

スタイルを適用する

スタイルのJSONをエクスポート 12 → アプリに組み込む

let styleJSONURL = Bundle.main.url(..., withExtension: "json")!
let styleJSON = try String(contentsOf: jsonURL)
let options = MapInitOptions(styleJSON: styleJSON)


完成

スタイルをカスタマイズ(=ベクトルタイルの描画方法を変更)した

ベクトルタイルどこいった?🤔

スタイルをカスタマイズ(=ベクトルタイルの描画方法を変更)

本当?🤔

  • コードに一切ベクトルタイルの気配がない
    • タイルインデックス(Z/X/Y)とかも1ミリも出てきていない...
let styleJSONURL = Bundle.main.url(..., withExtension: "json")!
let styleJSON = try String(contentsOf: jsonURL)
let options = MapInitOptions(styleJSON: styleJSON)


style.json の中身をのぞいてみると...

スタイルを定義するJSONファイルの中で、ベクトルタイルURLを指定している

https://a.tiles.mapbox.com/v4/{タイルセットID}/{z}/{x}/{y}.vector.pbf

(.pbfはProtocol Buffersでエンコードされたベクトルタイルの拡張子)

スタイルは明確にベクトルタイルを扱って見た目をカスタマイズする仕組みであることがわかる

3D表示


地図を描画できていれば、それを3D的に描画すればOK

右図は実際にiOSでSceneKitを使って地図画像(左図)を3D描画したもの


地形を立体的に表示するには?

Appleマップの3D表示

地形を立体的に表示するしくみ

標高のラスタータイルをハイトマップとして使う


標高タイルの利用方法

標高値がラスタータイルのRGB値にエンコーディングされている

MapTiler社のTerrain RGB形式 13 の場合:

elevation = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1)

→ 標高値をハイトマップとして3D化に使える


標高タイルをハイトマップ化

標高値 0m 〜 4000m を 0.0 - 1.0 に正規化したグレースケール画像として生成


地図のラスタータイルをハイトマップで3D化

ハイトマップを使って2D画像を3D化する実装は、デプスマップを使った3D化実装 14 と同様

富士山周辺の地図タイルを標高タイルで3D表示 15

iOSでの3D表示

MapboxでもMapKitでもカメラでどの角度から見るか設定するだけ

let cameraOptions = CameraOptions(
    center: ..., zoom: zoomLevel,
    pitch: 60) // 0度だと真上からみた2Dビューとなる
let options = MapInitOptions(cameraOptions: cameraOptions, styleJSON: styleJSON)
mapView = MapView(frame: view.bounds, mapInitOptions: options)

3D地形表示のコード 16

// 標高のラスタータイルをデータソースとして追加している
var demSource = RasterDemSource(id: "...")
demSource.url = "mapbox://mapbox.mapbox-terrain-dem-v1"
...
try! mapView.mapboxMap.addSource(demSource)
var terrain = Terrain(sourceId: demSource.id)
...
try! mapView.mapboxMap.setTerrain(terrain)

自キャラとモンスターの3Dモデルを設置

  • 緯度経度からシーン内のXY座標へ変換し設置する

    • → Webメルカトル投影法(前述)を使う
  • SDKを使う場合は意識する必要なし 17

デモ

  • マップの3D表示
  • 地形も標高タイルで3D化
  • 現在位置に自キャラ、公園のある位置にモンスターの3Dモデルを設置

デモ

まとめ

  • ラスターデータとベクトルデータ
  • 地図タイル/タイルインデックス
  • ベクトルタイルとスタイル
  • 3D表現

→ 「地図のしくみ」を理解

(おまけ)ビルの3D表示のしくみ

Appleマップでの建物の3D表示

❌️ ビルの3Dモデルを持っている? 18

⭕️ 「建物の領域を表す多角形」を「建物の高さ」情報(ベクトルタイル内に持っている)を用いて3D的に表示している 19

前述のkyotoのタイルセットの中身を見ると、

Kyotoタイルセットにおける京都タワーの一部データ

建物の領域を表す多角形に加えて、building レイヤーに

  • render_height
  • render_min_height

というフィールドがあり、高さの情報を持っていることがわかる。

続・GIS入門

プロポーザルでは言及していたものの、20分という発表時間内ではカバーしきれなかった内容について、その後の別イベントや記事で解説したものを抜粋します。

外部データの活用

国土地理院が配信している地形図や航空写真、JAXAやNASAの衛星データなどの外部データもiOSで活用できるようになります。(iOSDCプロポーザルより)

note.com

3D都市モデルの活用

PLATEAUで配布されている3D都市モデルの活用についても解説します。(iOSDCプロポーザルより)

その他派生記事・発表

zenn.dev

zenn.dev

www.docswell.com

おわりに

Sansan / Eightのモバイルアプリ開発を進めていく仲間を募集中です!選考評価なしで現場のエンジニアのリアルな声が聞けるカジュアル面談もありますので、ご興味のある方はぜひ面談だけでもお越しいただけたら幸いです。

open.talentio.com


  1. 今年は参加者1500人超で過去最高だったそうです。
  2. 2024.10.8に本記事を書いています。
  3. 地理空間情報:GISとは - 国土交通省」の記述:『様々な地理空間情報を重ね合わせて表示するためのシステム 』
  4. XYZ方式と呼ばれ、デファクトスタンダードとなっている。
  5. オライリー社の「ゼロから作るDeep Learning」シリーズより引用
  6. 北緯および南緯85.051129度以上の描画を諦め、地球全体を正方形の地図として表現する投影法。地理院地図やGoogle Maps等、多くの地図アプリケーションの土台となっている。
  7. 緯度経度をピクセル座標に変換するSwift実装
  8. MapTiler社が提供している、OpenStreetMap や Natural Earth などの全世界の地図データをベクトルタイル化したもの
  9. JapanやTokyoだと、データサイズやレイヤー数等で Mapbox Studioのアップロード制限 に引っかかる
  10. OpenMapTilesのスキーマは公開されている: openmaptiles.org/schema/
  11. Mapbox系マップSDK for iOSの系譜
  12. mapbox://styles/... のURIを直接指定する方法もある
  13. 国土地理院の標高タイルはまた違う形式でエンコーディングされている
  14. デプスマップを使った3D化実装の例: iOS-Depth-Sampler
  15. 地図タイルは「国土地理院 全国ランドサットモザイク画像」、標高タイルは「MapTiler Terrain RGB」を利用
  16. 詳細なコードとその解説は別途Zenn記事にまとめます
  17. MapKitでもMapbox SDKでも、緯度経度だけで地図上の然るべき位置に設置できる(2Dと同様)
  18. 3Dモデルを持っているケースもある
  19. この機能を司るクラスはMapbox SDKでは FillExtrusionLayer と命名されており、"extrusion" は「押出成形」の意味

© Sansan, Inc.