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があるのがわかります。

WayからのNode配列および座標の参照

実行結果

私の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にて配信した結果が以下となります。

点(Node)に加えて面(Way)が表示されている

配信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要素の対応をしようと思います。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次