トップMy OpenStreetMap > マイOSMバイナリレコード形式

マイOSMバイナリレコード形式

はじめに

OSMデータは自由にカスタマイズできる[1]。OSMを使い始めて約8年になる。 当初は、フリーソフト osm2pgsql[2] を使って、日本地図領域のデータ japan-latest.osm.pbf を PostgreSQL/PostGIS データベースにインポートして、Mapnik[3] を使ってレンダリングしていた。

しかし、現在は、osm2pgsql や Mapnik は使わず、 japan-latest.osm.pbf を解凍した japan-latest.osm(xml形式のテキストファイル)を使ってレンダリングしている。

パソコンでここに述べるバイナリ形式のレコードファイルを作成して、 スマホではそのレコードを読み込んで直接、地図をレンダリング(描画)している。

Parser出力レコード・フォーマット

前地図アプリGISとは少し変更した。フラグなどの head と レコード長rec_length を分離した。 レコード長の単位はこれまでと同じバイトとする。

osm_id は long、way_area は float、他は int である。

num_nodes は multipolygon の場合、先頭は outer polygon のノード数、 以降は inner polygon のノード数である。 line と polygon の場合は1要素で、ノード数となる。

head, rec_length, x0, y0, x1, y1, rec_id, osm_id, [way_area,] [xc, yc,]
{key, val,}+ [num_bus_routes,]{bus_route_id}* {num_nodes,}* {x, y,}*  

head:
  第0,1bit 0: point、1: line、2: polygon、3: multipolygon
  第4bit(0x10) xc, yc, 1:有り 0:無し
  第6bit(0x40) 1:bus_route_id有り 0:なし

OSMのxml形式のパ-スは複雑で、処理時間もかかるため、低中高ズーム用ファイルの作成は標準ズーム用ファイルから作成する。

パース処理はこれまでと同じく極座標でよい。class OSM でレコードを管理しているが、 このコントラクタで極座標をXY平面座標に変換する。

x、 y座標値を合わせて一つの long 値とすることもできる。 x, y の範囲は 0 ~ 230 であるから、結果の long 値は非負 (0 ~ 262)となる。

{key, val}+ には例外的に OSMタグ以外の情報を置くことも許す。 地名描画の競合調整をパソコンの OSMParser で行い、その結果は 特殊な key、val とする。 この情報はごく一部のレコードだけに置かれるため、レコード自体に特別なフィールドを設けず、 このようにするのが効率的である。

バス路線情報も特殊タグとする案もあるが、あまり複雑にしないために、独立配列とする。

OSMDivider出力レコード・フォーマット

Parserはプログラム規模も大きく、メンテナンスはその分、注意がいる。 また、プログラムの実行時間も大きい。このため、仕様の変更は少なくしたい。

これに対して、OSMDividerは小さいプログラムであり、実行時間も Parser の数十分の一に過ぎない。 このため、仕様の変更や実行のやり直しは簡単である。 出力レコード・フォーマットは基本的には Parser と同じであるが、細部は異なる。

x0, y0, x1, y1 はOSMParserの出力と同じ境界ボックスに戻す。  type 1, 2 では num_nodes を省略する。

head, length, x0, y0, x1, y1, [osm_id,] [way_area,] [xc, yc,] 
num_tags, {key, val,}+ [num_bus_routes,]{bus_route_id}* {num_nodes,}* {x, y,}*  

head:
  第0,1bit(0x03) 0: point、1: line、2: polygon、3: multipolygon
  第4bit(0x10) xc, yc, 1:有り 0:無し
  第5bit(0x20) osm_id  1:有り 0:無し  
  第6bit(0x40) 1:bus_route_id有り 0:なし
  第7bit(0x80) 1: 差分座標、0: 絶対座標
  第11~20bit(11)  zorder(-500~599) 
  第21~26bit(6)   wid           LandPolygon, building=yes なども含む
  第27~31bit(5)   minzoom      

(x0, y0) は、境界ボックスの左上座標で、常に絶対座標である。

(x1, y1) および (xc, yc)、(x, y) の (x0, y0) からの差分座標が全て2バイトで表現できる場合にかぎり、 差分レコードとする。

OSMDivider では writeShortメソッドのサポートは楽であるが、スマホではパフォーマンス上、 レコードを int配列に置くため、差分コードの場合、int 型変数を short型二つに分解する必要がある。 下位2バイトを short型として取り出す時注意がいる。

pointレコードの場合 (x1, y1) は省略可能であるが、レコード全体に占める比重は小さいため、 処理の単純化のために、(x1, y1) データを持たせる。値は (0, 0)である。差分レコード扱いとする。

ファイルサイズの縮小

メモリ使用量には余裕があるため、ファイルサイズの縮小は喫緊の課題ではない。 ディスクキャッシュの働きにより、ファイル読み込み時間が全体の地図表示時間に占める比率は極めて小さい。

復号時間がかかる高度な圧縮方法は論外であるが、境界ボックスの左上端を原点とする相対(差分)座標は サポートする。X座標とY座標(の差分値)をペアで4バイトで表現する。 これにより、全体的には3割程度のファイルサイズ縮小となる。

文字列のコード化

name、ref、operator などの OSMタグの値は任意の文字列である。 文字列には4バイト整数のコードを割り当て、 OSMバイナリレコードフォーマット上の val の値としている。

具体的には出現する文字列を改行区切り(テキストエディタで確認できる)の一つの大きな文字列(辞書) [char配列] としている。 各文字列の先頭位置(オフセット)をその文字列のコードとしている。

OSM地図データの基準は japan.osm であるが、この更新には全体で1時間ほどかかるため、 簡単なテストでは kanto.osm, kyushu.osm や、狭い範囲の最新データ hot.osm を使うこともある。 この場合、辞書は同じ方がスマホプログラムは楽である。 このため、OSMParser は、起動時に現在の辞書を CharBuffer cbWordsUTF16 に 読み込み、そのサイズを iniUTF16 に設定しておく。このとき、mapWords にも登録しておく。

パースでは、mapWords を使って、文字列をコードに変える。 mapWords になかった場合には、mapWordsおよび cbWordsUTF16 への追加登録を行う。

OSMデータの更新は数か月~半年に1回のペースなので、ほぼ間違いなく追加登録が起きる。 japan.osm、kanto.osm の順に更新した場合、kanto.osm では追加登録は起きない。 kanto.osm、japan.osm の順に更新した場合は、通常は kanto.osm で追加があり、 japan.osm でも追加がおきる。

iniUTF16 は追加があったかどうかを判別するために使う。

文字列タグの削除により、その時点では存在しない文字列が辞書に残ることがある。 量的には少ないので、問題はないが、気になる場合は、一旦、辞書ファイルを削除してから OSMParser を実行して、japanN.dat を作成すれば、現在使用中の文字列だけを含む辞書が作成される。 japanN.dat から、低中高ズーム用ファイル japanL.dat、japanM.dat、japanH.dat を作成する過程では辞書の更新は起きない。

可変長コードを使う場合、辞書は、よく現れる文字列順とした方が僅かであるが、 平均レコードサイズを小さくできる。OSMParser で出現頻度を調べて、出現頻度順の辞書を出力しておき、 その辞書を使ってパースし直せばよい。

しかし、全体としては文字列型タグを含むレコードの比率は小さいため、 そこで数バイトの短縮があっても、全レコードの平均サイズの縮小は微々たるものとなる。

スマホ側では辞書ファイルを int[] caWordsUTF16 に読み込むだけ。HashMap mapWords は要らない。

文字列の管理

これまでは、辞書ファイルをそのまま char配列 caWordsUTF16 に読み込んでいた。 単語の終わりは1文字ずつチェックして '\n' が現れるまでとしている。

    static void readWords(String file) {
        File f = new File(file);
        int file_length = (int) f.length();
        caWordsUTF16 = new char[file_length/2];
        try (DataInputStream dis = new DataInputStream(
                new BufferedInputStream(new FileInputStream(file)))) {
            int ix = 0;
            while (ix < caWordsUTF16.length) {
                char c = dis.readChar();
                caWordsUTF16[ix++] = c;
            }
        } catch (EOFException e) {
            ; // ファイル読み込み完了
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    static String getString(int code) {
        try {
            if (code < caWordsUTF16.length) {
                int end = code;
                while (end < caWordsUTF16.length && caWordsUTF16[end] != '\n' && end - code < 1024)
                    end++;
                return new String(caWordsUTF16, code, end - code);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

単語の切り出しを少し簡単にするため、末尾に '\n' を置く代わりに、先頭に単語の文字数を置くことにする。

    static void readWords(String file) {
        File f = new File(file);
        int file_length = (int) f.length();
        caWordsUTF16 = new char[file_length/2];
        try (DataInputStream dis = new DataInputStream(
                new BufferedInputStream(new FileInputStream(file)))) {
            for (int ix = 0, code = 0; ix < caWordsUTF16.length; ) {
                char c = dis.readChar();
                if (c == '\n') {
                    caWordsUTF16[code] = (char)(ix - code);
                    code = ++ix;    // '\n' の次(次の文字列の先頭=長さが入る位置)
                } else {
                    caWordsUTF16[++ix] = c;
                }   // 元の '\n' は無くなり、単語の文字は一つずつ後ろにずれ、先頭に長さが入る
            }
        } catch (EOFException e) {
            ; // ファイル読み込み完了
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    static String getString(int code) {
        if (code < caWordsUTF16.length-1) {
            return new String(caWordsUTF16, code+1, caWordsUTF16[code]);
        } 
        return null;
    }

リファレンス

[1] JA:初心者ガイド
[2] openstreetmap/osm2pgsql
[3] JA:Mapnik

開発メモ、履歴

2023.3.8 陸地ポリゴンのレンダリング

修正は一つ一つ確認しながら進める。

陸地ポリゴンは wid = 51 とした。osm_id、tag、way_area の出力はなしとした。