Golangによるosm.pbf読み込み4
前回の記事では、osmpbfpを使って、だけでなく線や面(Way、Relation)もGeoJSONファイル出力するプログラムを作成しました。
だたし、サイズの大きいosm.pbfを処理対象とする場合には、メモリなどのマシンリソースや処理時間が長いなどの問題があることがわかりました。
今回は、別のプロダクト(paulmach/osmpbf)を使って、点(Node)と線や面(Way)をGeoJSONファイル出力するプログラムを作成したいと思います。
ただし、MultiPolygonなどを表現するRelationについては今回は対象外とします。
paulmach/osmpbfとは
paulmach/osmpbfとは、Goにてosm.pbfを取り扱うためのパーサーで、Paul Mach氏(アメリカの方のようです)がGitHub上で公開しています。
ライセンス形態はMITライセンスです。
API仕様は、以下に示されています。
https://pkg.go.dev/github.com/paulmach/osm/osmpbf
paulmach/osmpbfの特徴
paulmach/osmpbf(以降、単にosmpbfとします)の特徴は、以下の4つです。
- osm.pbfを並列に順次読み込むシンプルな仕様のため処理速度が速い
- 同じ理由でメモリ使用量が少ない
- osm.pbfだけでなくosm.xmlのパーサー(osmxml)も提供される
- Osmium Tool(後述)のadd-locations-to-waysに対応しておりWay要素読み込み時にNodeの座標も取得できる
Relation->Wayの関係はアプリケーションにて構築しなければならないので、この点が残念です。
Nodeは座標点、WayはNodeの集合、RelationはWayの集合として表現されます。
また、属性はタグと値で表現されNode、Way、Relationともに保有できます。
osmpbfのパッケージ構造
説明のタイミングとして微妙なのですが、ここでosmpbfのパッケージ構造を簡単に説明します。
osmpbfパッケージは、osmパッケージに依存んしており、osm.pbfのプリミティブ的なNodeやWayなどのプログラム的な表現(構造体)は、osmパッケージに収容されています。
後述しますが、osm.pbfから図形要素を取得するscanner型は、osmpbfパッケージに収容されています。
osm/osmpbfの構造
osm
├── element.go
├── feature.go
├── osm.go
├── object.go
├── node.go
├── way.go
├── relation.go
├── tag.go
└── osmpbf
└── scanner.go
ベクトルタイル作成までの流れ
以下にベクトルタイル配信までの流れを示します。
- 手順1 Osmium ToolによるNode-Way関係設定
- osm.pbfを操作するユーティリティOsmium Toolを使ってWayにNode情報を付与します。
- 手順2 osmpbfを使ってGeoJSON化
- osmpbfを使ったGoアプリケーションにより加工済みosm.pbfからGeoJSONを生成します。
- 手順3 Tippecanoeを使ってmbtiles化
- GitHub上で公開されているTippecanoeを使ってGeoJSONをmbtilesに変換します。
- 手順4 mbtilesを配信
- 地図配信サーバーを使って作成したmbtilesを配信します。今回は、TileServer-GLを使用します。
本記事では、手順1、2について説明します。
手順3、4については、以下の記事にて説明していますので、そちらをご確認ください。
Osmium ToolによるNode-Way関係設定
Osmium ToolによるNodeとWayの関係設定について、順次説明します。
Osmium Toolとは
Osmium Toolとは、osm.pbf(およびosm.xml)の内容を参照したり他の形式に変換したりするツールで、CLI(コマンドラインインタフェース)として提供されます。
シェープファイルを操作する時に使うGDALのogr2ogrやogrinfoと同じイメージです。
マニュアルは以下にあります。(英語です)
https://osmcode.org/osmium-tool/
Osmium Toolのインストール
Ubuntuの場合
バイナリパッケージが存在しますので以下のようにインストールできます。
$ apt-get install osmium-tool
Macの場合
バイナリパッケージがありますので、Home Brewで以下のようにインストールできます。
% brew install osmium-tool
CentOS8の場合
yumやdnfでインストールできるバイナリパッケージがありませんので、GitHubからソースを取得してビルドします。
基本的にはGitHubに書いてある通りですが、正確には以下のように作業します。
$ mkdir work
$ cd work
$ git clone https://github.com/mapbox/protozero
$ git clone https://github.com/osmcode/libosmium
$ git clone https://github.com/osmcode/osmium-tool
$ yum install libosmium-devel
$ cd osmium-tool
$ mkdir build
$ cd build
$ cmake ..
$ make
$ make install
GitHubに書いてあるインストール手順との違いは以下となります。
- yumでlibosmium-develをインストール
- make installの実行
インストールが成功すると、コマンドosmiumが使用できるようになります。
以下は、CentOS8でのバージョン確認結果となります。
$ osmium --version
osmium version 1.13.1 (v1.13.1-18-g202def6)
libosmium version 2.16.0
Supported PBF compression types: none zlib
Copyright (C) 2013-2021 Jochen Topf <jochen@topf.org>
License: GNU GENERAL PUBLIC LICENSE Version 3 <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
NodeとWayの関係設定
osmiumコマンドのadd-locations-to-waysオプションにより、NodeとWayの関係を追加したosm.pbfファイルを作成することができます。
以下は、日本全国のosm.pbf(japan-latest.osm.pbf)に、NodeとWayの関係を設定した新しいファイルjapan-low.osm.pbfを作成する例です。
$ osmium add-locations-to-ways -n -o japan-low.osm.pbf japan-latest.osm.pbf
[======================================================================] 100%
私のマシン(Mac mini M1 メモリ16GB)では、上記の実行時間は、3分ほどでした。
以下のように、NodeとWayが関連づけられたファイルは元ファイルの1.64倍程度となっています。
osmiumのadd-locations-to-waysオプションのマニュアルは、以下にあります。
https://docs.osmcode.org/osmium/latest/osmium-add-locations-to-ways.html
GeoJSON出力プログラム
今回は、リポジトリ上のexample_stats_test.goを改修して、日本全国のosm.pbf(japan-latest.osm.pbf)のNode(点)要素の学校(amenityタグ=school)を抽出し、GeoJSONファイルに変換するプログラムを作成します。
https://github.com/paulmach/osm/tree/master/osmpbf
作成したソースコードは以下の通りです。
package main import ( "context" "fmt" "log" "os" "runtime" "strings" "time" "github.com/paulmach/osm" "github.com/paulmach/osm/osmpbf" ) func main() { start := time.Now() f, err := os.Open("./japan-low.osm.pbf") if err != nil { fmt.Printf("could not open file: %v", err) os.Exit(1) } defer f.Close() // ファイルを書き込み用にオープン (mode=0666) file, err := os.Create("./output.json") if err != nil { log.Fatal(err) } defer file.Close() // FeatureCollectionレコード(ヘッダー的なもの)を出力 file.WriteString("{\"type\":\"FeatureCollection\",\"features\":[\n") nodes, ways, relations := 0, 0, 0 snodes, sways, srelations := 0, 0, 0 cpu := runtime.NumCPU() scanner := osmpbf.New(context.Background(), f, cpu) defer scanner.Close() var endl bool for scanner.Scan() { switch e := scanner.Object().(type) { case *osm.Node: if e.Tags.Find("amenity") == "school" { snodes++ // 最後のレコード出力時にはカンマを出力しない if endl { file.WriteString(",\n") } else { endl = true } // 要素情報の出力 file.WriteString("{\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",") file.WriteString(fmt.Sprintf("\"coordinates\":[%.7f,%.7f]}", e.Lon, e.Lat)) // 属性文字のエスケープ関連文字の訂正 if strings.Contains(e.Tags.Find("name"), "\\") { file.WriteString(fmt.Sprintf(",\"properties\":{\"name\":\"%s\"}}", strings.Replace(e.Tags.Find("name"), "\\", "", -1))) } else if strings.Contains(e.Tags.Find("name"), "\n") { file.WriteString(fmt.Sprintf(",\"properties\":{\"name\":\"%s\"}}", strings.Replace(e.Tags.Find("name"), "\n", "", -1))) } else if strings.Contains(e.Tags.Find("name"), "\"") { file.WriteString(fmt.Sprintf(",\"properties\":{\"name\":\"%s\"}}", strings.Replace(e.Tags.Find("name"), "\"", " ", -1))) } else { file.WriteString(fmt.Sprintf(",\"properties\":{\"name\":\"%s\"}}", e.Tags.Find("name"))) } } nodes++ case *osm.Way: if e.Tags.Find("amenity") == "school" { sways++ // 最後のレコード出力時にはカンマを出力しない if endl { file.WriteString(",\n") } else { endl = true } // 要素情報の出力 if e.Polygon() { file.WriteString("{\"type\":\"Feature\",\"geometry\":{\"type\":\"Polygon\",") file.WriteString("\"coordinates\":[[") } else { file.WriteString("{\"type\":\"Feature\",\"geometry\":{\"type\":\"LineString\",") file.WriteString("\"coordinates\":[") } for i, v := range e.Nodes { if i > 0 { file.WriteString(",") } file.WriteString(fmt.Sprintf("[%.7f,%.7f]", v.Lon, v.Lat)) } if e.Polygon() { file.WriteString("]]}") } else { file.WriteString("]}") } // file.WriteString(fmt.Sprintf("\"coordinates\":[%.7f,%.7f]}", e.Lon, e.Lat)) // 属性文字のエスケープ関連文字の訂正 if strings.Contains(e.Tags.Find("name"), "\\") { file.WriteString(fmt.Sprintf(",\"properties\":{\"name\":\"%s\"}}", strings.Replace(e.Tags.Find("name"), "\\", "", -1))) } else if strings.Contains(e.Tags.Find("name"), "\n") { file.WriteString(fmt.Sprintf(",\"properties\":{\"name\":\"%s\"}}", strings.Replace(e.Tags.Find("name"), "\n", "", -1))) } else if strings.Contains(e.Tags.Find("name"), "\"") { file.WriteString(fmt.Sprintf(",\"properties\":{\"name\":\"%s\"}}", strings.Replace(e.Tags.Find("name"), "\"", " ", -1))) } else { file.WriteString(fmt.Sprintf(",\"properties\":{\"name\":\"%s\"}}", e.Tags.Find("name"))) } } ways++ case *osm.Relation: if e.Tags.Find("amenity") == "school" { srelations++ } relations++ } } // FeatureCollection終端を出力 file.WriteString("]}\n") if err := scanner.Err(); err != nil { fmt.Printf("scanner returned error: %v", err) os.Exit(1) } end := time.Now() fmt.Println("Start:", start) fmt.Println("End :", end) fmt.Println("Elapsed:", end.Sub(start)) fmt.Println("nodes:", nodes) fmt.Println("ways:", ways) fmt.Println("relations:", relations) fmt.Println("snodes:", snodes) fmt.Println("sways:", sways) fmt.Println("srelations:", srelations) }
以降にポイントを解説します。
スキャナの生成とデータ取得
osm.pbfのパーサーは、スキャナ(Scanner)として提供されています。
以下に、スキャナの生成と図形要素取得部分を抜粋します。
scanner := osmpbf.New(context.Background(), f, cpu) for scanner.Scan() { switch e := scanner.Object().(type) { case *osm.Node: // Nodeに対する処理を実装 case *osm.Way: // Wayに対する処理を実装 case *osm.Relation: // Relationに対する処理を実装 }
- スキャナはosmpbf.New関数により取得
- osmpbf.Newの第3引数は並列数(=並列実行スレッド数)
- scanner.Scan()は次のオブジェクトが存在するかのチェックを行う(存在しない場合はfalse)
- scanner.Object()により、変数eに要素(Node、Way、Relation)を取得
要素の構造とタグ取得
取得した要素e(Elementの意味だと思います)には、osm.pbfに設定されているNodeやWay、Relationの持つ座標やプロパティ(属性)などが設定されています。
例えばNodeは、以下のような構造体となっています。(osm/node.goより抜粋)
// Node is an osm point and allows for marshalling to/from osm xml. type Node struct { XMLName xmlNameJSONTypeNode `xml:"node" json:"type"` ID NodeID `xml:"id,attr" json:"id"` Lat float64 `xml:"lat,attr" json:"lat"` Lon float64 `xml:"lon,attr" json:"lon"` User string `xml:"user,attr" json:"user,omitempty"` UserID UserID `xml:"uid,attr" json:"uid,omitempty"` Visible bool `xml:"visible,attr" json:"visible"` Version int `xml:"version,attr" json:"version,omitempty"` ChangesetID ChangesetID `xml:"changeset,attr" json:"changeset,omitempty"` Timestamp time.Time `xml:"timestamp,attr" json:"timestamp"` Tags Tags `xml:"tag" json:"tags,omitempty"` // Committed, is the estimated time this object was committed // and made visible in the central OSM database. Committed *time.Time `xml:"committed,attr,omitempty" json:"committed,omitempty"` }
タグを取得する場合は、以下のように要素eの持つTags型のFind関数(osm/tag.goに存在)を呼び出し、タグのキー(”amenity”)に一致する値を取得し対応する値(Value)が”school”と一致するかを判定しています。
case *osm.Node: if e.Tags.Find("amenity") == "school" {
WayからNodeの参照
この部分が今回の重要な部分です。
osm.pbf上でのWay要素は、NodeのIDを配列で保有しているのですが、座標は保有していないためこれが必要な場合はユーザーアプリケーションにてNodeの座標をバッファリングしたりデータベースに格納したりといった実装が必要です。
前述のosmiumのadd-locations-to-waysオプションにて作成したosm.pbfでは、(おそらく)Way要素にNode情報を保有しています。
osmpbfでは、以下のように要素e(Way)のメンバーであるNodesからfor renge
により順次Node要素を変数vに取得します。
for i, v := range e.Nodes { if i > 0 { file.WriteString(",") } file.WriteString(fmt.Sprintf("[%.7f,%.7f]", v.Lon, v.Lat)) }
osm/Way.goでは、以下のような定義となっており、Way->WayNodes[]->WayNode.Latといった関係性となっています。
// Way is an osm way, ie collection of nodes. type Way struct { XMLName xmlNameJSONTypeWay `xml:"way" json:"type"` ID WayID `xml:"id,attr" json:"id"` User string `xml:"user,attr" json:"user,omitempty"` UserID UserID `xml:"uid,attr" json:"uid,omitempty"` Visible bool `xml:"visible,attr" json:"visible"` Version int `xml:"version,attr" json:"version,omitempty"` ChangesetID ChangesetID `xml:"changeset,attr" json:"changeset,omitempty"` Timestamp time.Time `xml:"timestamp,attr" json:"timestamp"` Nodes WayNodes `xml:"nd" json:"nodes"` Tags Tags `xml:"tag" json:"tags,omitempty"` // Committed, is the estimated time this object was committed // and made visible in the central OSM database. Committed *time.Time `xml:"committed,attr,omitempty" json:"committed,omitempty"` // Updates are changes to the nodes of this way independent // of an update to the way itself. The OSM api allows a child // to be updated without any changes to the parent. Updates Updates `xml:"update,omitempty" json:"updates,omitempty"` // Bounds are included by overpass, and maybe others Bounds *Bounds `xml:"bounds,omitempty" json:"bounds,omitempty"` } // WayNodes represents a collection of way nodes. type WayNodes []WayNode // WayNode is a short node used as part of ways and relations in the osm xml. type WayNode struct { ID NodeID `xml:"ref,attr,omitempty"` // These attributes are populated for concrete versions of ways. Version int `xml:"version,attr,omitempty"` ChangesetID ChangesetID `xml:"changeset,attr,omitempty"` Lat float64 `xml:"lat,attr,omitempty"` Lon float64 `xml:"lon,attr,omitempty"` }
実際にブレークポイントを設定した状態が以下です。
変数e(Way)の中にNodes配列があり、そのメンバにID、Lat、Lonがあるのがわかります。
実行結果
私のPC(Mac mini M1 メモリ16GB)にて、japan-latest.osm.pbfを処理したところ、以下のように約23秒で処理が終了しました。
出力されたJSONファイルの要素数は以下のような結果でした。
- Node: 20,400(20,386)
- Way : 25,300(25,311)
- Relation: 301(301)
※()内の数値は、osmpbfperser-goでの変換結果の要素数。差異の原因は不明です。
抽出結果のGeoJSONをtippecanoeにてmbtilesに変換してtileserver-glにて配信した結果が以下となります。
配信URLは以下ですので、興味ある方は確認してみてください。
https://labo.takamoto.biz/learngo/
背景地図にはGeolonia社が開発しているOceanusを使用しています。
まとめ
今回は、osmuimとpaulmach/osmpbfを使って、osm.pbfからGeoJSONファイルを抽出するプログラムを作成しました。
ソースコードは、以下のGitHubリポジトリにて公開しています。
https://github.com/takamotobiz/learngo
今回使用したpaulmach/osmpbfはとても高速でよいと思いますが、Relation要素への対応にアプリケーションの開発が必要など課題があります。
次回は、paulmach/osmpbfを使ってRelation要素の対応をしようと思います。
コメント