トップ地図アプリMap > OSMバイナリレコードファイル形式

OSMバイナリレコードファイル形式

はじめに

これまでファイル形式の細部は何度か変更してきた。 昨年(2025年)には、閉ループタイプの natural=cliff のレンダリングの不備に対応するため、 reversedフラグを追加した。今後も、このようなケースが起きるかも知れないが、 ファイル形式の修正は極力避け、やむ得ず変更するときは、なるべく互換性を保ちたい。

Encoder で中間ファイルを作り、Parser で最終的な OSMバイナリレコードを作成する。 地図アプリにとって重要なのは、最終的な OSMバイナリレコード形式である。

OSM(xml形式)データは大きくは node、way、relation セクションで構成される。 中間ファイルは、三つで node.obf、way.obf、relation.obf の順に作成される。

OSMバイナリレコードは図形レコードであり point、line、polygon、multipolygon レコードである。

OSMの node、way、relationオブジェクトから直接、図形(地図)を描くことは難しいため、 図形描画に適したOSMバイナリレコードを作り出す。

標準OSM地図では通常はこのOSMバイナリレコードを osm2pgsql というOSM専用のフリーソフトを使って、 PostgreSQL/PostGIS データベースに取り込む。 そして、Mapnikという地図ソフトを使って、タイル地図画像データを作成する。

OSMに触れて11年になる。当初の数年は上記の方法でOSMタイル地図画像データを作っていた。 osm2pgsql や Mapnik は地図専用ソフトであり、使いこなすのは容易ではない。 osm2pgsql は Windows では使えない時期もあり、 Windows上で Linux を動かす環境を構築したこともある。 osm2pgsql や Mapnik のバージョンアップにも対応しなければならない。 これらの煩雑さから、osm2pgsql、PostgreSQL/PostGIS、Mapnik から離れて、 これら全てを自作プログラムで対応することにした。

完全に Mapnik互換の地図を作るのは難しいが、個人使用ではMapnikに近い地図を作るのは それほど難しくはない。OSMバイナリレコードはパソコンで作成するが、 Mapnikに相当する処理はスマホでも可能である。

Encoder

Encoderの出力フォーマット:   文字コードは UTF-16。
length:4, osm_id:8, uid:4, time:4, lon, lat, {key, val}*                         ----- Node   
length:4, osm_id:8, uid:4, time:4, bbox:16, num_nds, {lon, lat}*, {key, val}*    ----- Way  
length:4, osm_id:8, uid:4, time:4, num_members, {type, ref, role}*, {key, val}*  ----- Relation
bbox: minlon, minlat, maxlon, maxlat

lengthは自分を含むレコード長(単位:バイト)。

uid、userはペアでコード化する。現在(2024年10月)、japan.osm で、4万弱のため、2バイトでもよいが、 4バイトを割り当てる。

データ更新時刻 time は分単位として、4バイトを割り当てる。精度はもっと下げてもよいので、2バイト化も可能である。 しかし、レコードサイズの縮小に拘らない方がよい。

role は enum で定義し、レコード上は2バイトで表す。inner、outer などいくつかについては、 レンダリングに関係するが、レンダリングでは無視するものが殆どである。

【ソースデータ例】
  <node id='31253515' timestamp='2022-01-09T09:59:40Z' uid='7707735' user='zyxzyx'
 visible='true' version='5' changeset='115935292' lat='35.6536052' lon='139.7601994' />

japan のファイルサイズは node 136MB、way 3.99GB、relation 37.0MB、計4.13GBとなった。

当面、レンダリング対象外のrelationを除くと、relation は 28MBとなった。

maxId=2D922B13B, nodes=FF19CB7, ways=23BD110, rels=28F11(167697), 実行時間: 32.77分

relation member のノードは 153,588 nodes, 11.9 MB、ウェイは 677,412 ways, 217.8 MB であった。 ウェイレコードは平均で 300B を超える。

全体の5%程度であるから、ブロックファイルとの重複は問題でない。 同じようにzoom 8か9で分割すれば、入れ替え頻度はzoom 12分割のブロックファイルより小さくなるので、 osm_idによるメンバー取り出し平均時間は十分に小さいであろう。

レコードに組み立てた場合、行政境界などでは同じウエイが2回以上使われる。また、 境界ボックスは大きくなるため、zoom 12だけの分割はできず、zoom 7、8といった分割も必要となる。  

現在は、バス路線や行政境界名の描画はブロックファイルの総サイズを抑えるために、工夫をしている。 このような工夫をやめ、プログラムをシンプルにすると、ブロックファイルの総サイズは更に何割か大きくなる。

つまり、relationレコードを事前に組み立て、色んな工夫をやめると、総サイズが大きくなりすぎる。 SDカードの容量は問題ないが、パソコンからスマホ/タブレットへのファイル転送時間が増大する。

しかし、プログラムの簡単化を一番重視すると、総サイズの増大を許容すべきかも知れない。

レンダリング対象外の巨大なポリゴン/マルチポリゴンを除外する

北海道、本州、四国、九州などは除外する。ファイルサイズの縮小は僅かである(37.0 -> 36.7MB)。

Parser

空間インデックスを使う代わりに、ブロック分割する。

{lon,lat}* は先頭は現状の絶対値、次からは前のノードとの差分値を2バイトペアで表す。 2バイトで表せないときは中間に必要なだけノードを挿入して、差分が2バイトで表せるようにする。

length:4, head:4, osm_id:8, uid:4, time:4, lon:4, lat:4, {key:2,val:2*}*                           -- point   
length:4, ~同上~ time:4, bbox:16, num_nds:4, {lon4/2,lat4/2}*, {key:2,val:2*}*                   -- line/polygon  
length:4, ~同上~ time:4, bbox:16, num_polys:2, {num_nds:4}*, {lon:4/2,lat:4/2}*, {key:2,val:2*}* -- multipolygon
bbox: minlon, minlat, maxlon, maxlat

num_ndsはごく一部のレコードで、2バイト幅を超えるものがあるので、4バイト幅とする。

{lon,lat}* は先頭が 4バイトペアで次からは前との差分を2バイトペアで表す。必要に応じて、ノード間に 1つ以上のノードを挿入して、差分が2バイトに収まるようにする。

relationについてはかなりの処理がいるが、 node と way については、単純にブロック分割するだけである。 しかし、polygon についてはいずれ中心位置の算出が必要になる。

まずは node と way を zoom 12 と zoom 8 で 分割してみる。

zoom 12 は 3.33GB、zoom 8 は 32MB となった。このときは zoom 12 に置く場合、重複が3以上となる場合に、zoom 8に置いた。

zoom 12 に置く場合、重複が起きるとき、zoom 8 に置くと zoom 12 は 2.92GB、zoom 8 は 252MB となった。zoom 8 では重複がありうる。zoom 8 での最大は 27.8MB となった。


[2024.11.25]

head は上位4ビットを type、下位28ビットを rec_id とする。osm_id はレコードの識別には使えない。 マルチポリゴンの場合、同じ outer polygon が二つ以上の場合、outer polygon 毎のレコードとなるため、osm_id は同じとなる。

[2026.2.23]

rec_id はレンダリングでは使用していないため、wayレコード(開ループ、閉ループ)では rec_id の出力はせず、OSMバイナリレコードの length の次の head は先頭の 4ビットが type で、 最下位ビットを fReversedフラグとしている。

out.writeInt((head<<28) + (fReversed ? 1 : 0));

スマホの地図アプリでは、fReversedフラグを使っているが、PC地図アプリでは使っていない。 閉ループの natural=cliff のレンダリングでトゲの方向が誤っていることがあるが、それ以外の問題はない。

relation

上記の node、way は relation のメンバーを含んでいる。ただし、relation では同じ way、node を繰り返し使用する。 例えば、バス路線relation では、同じバス停、同じ道を複数の路線が使う。 また、行政境界線は少なくとも、二つの行政が共通で使う。3回以上共用されることも珍しくない。

このため、単純に relation で lineレコードや polygon/multipolygon レコードを組み立てると、 総レコードサイズは大幅に増加する。また、polygon/multipolygon は way単独のものより、 はるかに大きくなることが多く、zoom 8のブロックをまたぐものが多くなり、この重複により、 総ファイルサイズが一層大きくなる。

バス路線や航路などはレンダリングでは、繋いで長いものにする必要はないが、 広域森林や大きな湖などは必ず大きな polygon/multipolygon にする必要がある。 

バス路線は独立したレコードにするのではなく、道路レコードにバス路線IDを含めておくだけでもよい。 一般に point/lineレコードの場合は複数のrelationが共用する場合、この方をとれる。

行政境界の場合も、境界線と線の内側に行政名を描くだけの場合、同じように共用できる。 しかし、地図を区別に異なる色で塗りつぶす場合には、polygon/multipolygonにしなければならない。

まずは、広域森林、湖(relationによる水域)など、レンダリングでは polygon/multipolygon化が必須のものについて、 relation処理でpolygon/multipolygonレコードを作り、総ファイルサイズがどれくらい増加するかを調べたい。

まず、ハイズームだけを作成した。zoom 12 が 2.92GB、zoom 8 が 351MB となった。 この後、行政境界、バス路線などで増加するが、思ったよりは少なくて済みそうである。

バイナリレコードの管理

日本地図としてはこのバイナリレコードの総サイズは現在で数GBの大きさとなる。 パソコンの場合にはこれを PostgreSQL/PostGIS に読み込み、空間インデックスを付与すると、 空間検索により、あるタイルのレンダリングに関係するレコードだけを素早く取り出すことができる。

OSM地図歴11年になるが、当初の数年(あるいは4、5年か?)は、osm2pgsql により PostgreSQL/PostGIS にデータを読み込ませ、 Mapnikによるレンダリングを行ってきた。

しかし、個人利用では、osm2pgsql や Mapnik のバージョンアップに追従するのは楽ではない。まして、スマホに移ると、 これらのアプリは動かない。

やがて、PostgreSQLやMapnikの使用はやめ、OSMバイナリレコードはあるzoom値で分割して管理して、自前でレンダリングするようになった。

現在、日本地図は、陸地レンダリング用 lands.zip と japan.zip に膨大な数の分割されたファイルを格納している。

レンダリング処理を下に示す。レンダリングは大きくは低、中、高ズームに分けている。高ズームはOSMデータそのものを使うが、 中低ズームはこれを簡略化したレコードを使う。

低ズームでは zoom 3 、中ズームでは zoom 9 、高ズームでは zoom 12 で分割したものを使う。 分割では、複数のタイルにまたがるレコードは同じレコードを重複して持っている。 高ズームでは、この重複が多くなりすぎるため、二段にわけ、zoom 12 で重複が多くなるものは zoom 9 に置くように している。zoom 9 でも重複が多いものもあるが、それは容認している。

このようにしているが、全体としては3、4割の重複は起きている。PostgreSQL/PostGIS の場合にも 空間インデックスはインデックスなしに比べて3,4割のデータベースファイルサイズの増大は起きる。

つまり、タイル分割は空間インデックスに当たるものと解釈できる。

    Bitmap render(Tile tile) {
        num_rects = 0;
        set(tile.src, tile.zoom, tile.x, tile.y);
        Bitmap bmp = BitmapPool.get();
        Canvas canvas = new Canvas(bmp);

        //==== 陸地ポリゴンのレンダリング ====//
        ixOsm = 0;
        Tile.Range range = zoom <= 7 ? Tile.Range.low : Tile.Range.high;
        int Zoom = zoom <= 7 ? 0 : 6;
        Block.getOSMs(Tile.Source.lands, range, Zoom, this);
        canvas.drawRect(0, 0, Map.PX, Map.PX, paintWater);
        for (int n = 0; n < ixOsm; n++) {
            osms[n].fillPolygon(canvas, this, paintLand, paintWater);
        }
        freeOSMs();

        //==== OSMバイナリレコードによるレンダリング ====//
        ixOsm = 0;
        long start = System.currentTimeMillis();
        if (zoom <= 9) {            // 低ズーム ~9
            Block.getOSMs(src, Tile.Range.low, 3, this);
        } else if (zoom <= 13) {    // 中ズーム 10~13
            Block.getOSMs(src, Tile.Range.mid, 9, this);  //7
        } else {                       // 高ズーム 14 ~
            Block.getOSMs(src, Tile.Range.high, 9, this); // 7
            Block.getOSMs(src, Tile.Range.high, 12, this);// 12
        }
        renderLayers(canvas, osms, ixOsm);
        freeOSMs();
        return bmp;
    }

上記の分割された膨大なファイルを長らく階層ディレクトリで管理してきた。最近になって、zipファイルで管理するようになった。 タイル画像については zip よりも独自のアーカイブファイルの方が効率がよいことが確認できた。

アーカイブしたタイルは id(zoom, x, y) 順としている。y の値が近いものは近くにあるが、x についてはそうではない。

例えば、x(3バイト) と y(3バイト) をビット単位で交互に組み合わた 6バイトを id に使うと、一般には (x, y) の値が近いものは id の値も近くなる(Z曲線、モートン順序)。

AI検索結果: JavaによるZ曲線(2次元)実装プログラム

public class MortonCode64 {

    /**
     * 2次元座標 (x, y) を64ビットのMortonコードに符号化する
     * x, y はそれぞれ 0 ~ 2^32-1 の範囲 (int型で符号なしとして扱う)
     */
    public static long encode2D(int x, int y) {
        long xLong = Integer.toUnsignedLong(x);
        long yLong = Integer.toUnsignedLong(y);
        
        return interleave(xLong, yLong);
    }

    /**
     * 2つの32ビット整数を交互にインターリーブして64ビットにする
     */
    private static long interleave(long x, long y) {
        // ビットを拡散させる(Magic Number手法)
        x = (x | (x << 16)) & 0x0000FFFF0000FFFFL;
        x = (x | (x << 8))  & 0x00FF00FF00FF00FFL;
        x = (x | (x << 4))  & 0x0F0F0F0F0F0F0F0FL;
        x = (x | (x << 2))  & 0x3333333333333333L;
        x = (x | (x << 1))  & 0x5555555555555555L;

        y = (y | (y << 16)) & 0x0000FFFF0000FFFFL;
        y = (y | (y << 8))  & 0x00FF00FF00FF00FFL;
        y = (y | (y << 4))  & 0x0F0F0F0F0F0F0F0FL;
        y = (y | (y << 2))  & 0x3333333333333333L;
        y = (y | (y << 1))  & 0x5555555555555555L;

        // yを1つずらしてxと合成 (y,x,y,x...の順)
        return (y << 1) | x;
    }

    /**
     * Mortonコードから2次元座標にデコードする
     */
    public static int[] decode2D(long morton) {
        long x = 0;
        long y = 0;

        // ビットを圧縮して戻す
        x = morton & 0x5555555555555555L;
        y = (morton >> 1) & 0x5555555555555555L;

        x = (x | (x >> 1))  & 0x3333333333333333L;
        x = (x | (x >> 2))  & 0x0F0F0F0F0F0F0F0FL;
        x = (x | (x >> 4))  & 0x00FF00FF00FF00FFL;
        x = (x | (x >> 8))  & 0x0000FFFF0000FFFFL;
        x = (x | (x >> 16)) & 0x00000000FFFFFFFFL;

        y = (y | (y >> 1))  & 0x3333333333333333L;
        y = (y | (y >> 2))  & 0x0F0F0F0F0F0F0F0FL;
        y = (y | (y >> 4))  & 0x00FF00FF00FF00FFL;
        y = (y | (y >> 8))  & 0x0000FFFF0000FFFFL;
        y = (y | (y >> 16)) & 0x00000000FFFFFFFFL;

        return new int[] {(int)x, (int)y};
    }

    public static void main(String[] args) {
        int x = 10; // 0...01010
        int y = 5;  // 0...00101
        
        long morton = encode2D(x, y);
        System.out.println("Coordinates: (" + x + ", " + y + ")");
        System.out.println("Morton Code (Dec): " + Long.toUnsignedString(morton));
        System.out.println("Morton Code (Bin): " + Long.toBinaryString(morton));

        int[] decoded = decode2D(morton);
        System.out.println("Decoded: (" + decoded[0] + ", " + decoded[1] + ")");
    }
}

より高度な方法が色々あるが、複雑な方法は避けたい。

現在は、japan-high12, japan-high9, japan-mid9, japan-low3 下にある多数のファイルをひとまとめにして japan.zip と しているが、これを japan-high12.pak, japan-high9.pak, japan-mid9.pak, japan-low3.pak という4つのファイルとする。 (x, y)座標値はそれぞれ short型の非負の整数で表せるため、合わせて、32ビットの morton コードとして表現できる。

一方、offset については、現在でも japan-high12 の総サイズは 4.66GB であり、この先、増えていくため、バイト単位では 32ビットでは足りず、64ビットにする必要がある。4バイト境界とか8バイト境界を使えば、32ビット表現も可能であるが、 バイト境界の方が分かりやすい。よって id を 32ビット、offset を 64ビットとする。

ファイル数は一番多いjapan-high12 でも 8,000強に過ぎない。全体でも 2万ファイル未満である。 したがって、ファイル当たり 12バイトの全管理データをメモリに常駐させることはたやすい。

リファレンス

[1] OpenStreetMap Data Extracts