📦

GeoPackage ファイルを SQLite で開いて中身を覗いてみる

に公開

GeoPackage は、OGC 標準の規格で、SQLite をベースにしたフォーマットで、ベクタデータもラスタデータも扱えて、空間インデックスもあって...

みたいなところまでは知ってますが、「SQLite をベースにしたフォーマット」というのは具体的にどういう仕様なんでしょうか。意外とそこを解説してくれている日本語記事が見当たらない気がしたので、実際のデータを覗いてみることにしました。

(覗いてわかりやすく解説するぞ!、みたいな記事になればよかったんですが、不用意に長くなってしまって、もはや自分用メモですが、誰かの役に立てばと思い公開します...)

使ったデータ

QGIS で[レイヤ]→[レイヤを作成]→[新規GeoPackageレイヤ]から、3点ほど適当な場所に点を打った GeoPackage ファイルを作りました。good_places という名前です。

仕様書

ふつうに「GeoPackage spec」とかで検索すると https://www.geopackage.org/spec/ がいちばん上に出てきますが、先頭に注意書きで書かれているように、これは正式な OGC 標準の規格ではありません。OGC のページからリンクされているこちらが正式な文章(現時点で最新版は v1.4.0)です。まあ、そんなに違いはないとは思います。

https://www.geopackage.org/spec140/index.html

読み込んでみる

GeoPackage ファイルは、SQLite のデータなので SQLite で読むことができます。

$ sqlite3 /path/to/tmp.gpkg

あと、出力が分かりやすいようにちょっと設定を変更しておきます。

sqlite> .header on
sqlite> .mode line

テーブル一覧

.tables でテーブル一覧を見ることができます。good_places という名前で作成したデータですが、good_places 以外にも何やらいろいろなテーブルがあります。

sqlite> .tables
good_places                        gpkg_tile_matrix
gpkg_contents                      gpkg_tile_matrix_set
gpkg_extensions                    rtree_good_places_geometry
gpkg_geometry_columns              rtree_good_places_geometry_node
gpkg_ogr_contents                  rtree_good_places_geometry_parent
gpkg_spatial_ref_sys               rtree_good_places_geometry_rowid

メインのテーブル

good_places は主なデータを保持するテーブルです。ジオメトリも属性データもここに入っています。.schema でスキーマを確認してみると...

sqlite> .schema --indent good_places
CREATE TABLE IF NOT EXISTS "good_places"(
  "fid" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
  "geometry" POINT,
  "name" TEXT,
  "score" MEDIUMINT
);
CREATE TRIGGER "rtree_good_places_geometry_insert" AFTER INSERT ON "good_places" WHEN (new."geometry" NOT NULL AND NOT ST_IsEmpty(NEW."geometry")) BEGIN INSERT OR REPLACE INTO "rtree_good_places_geometry" VALUES (NEW."fid",ST_MinX(NEW."geometry"), ST_MaxX(NEW."geometry"),ST_MinY(NEW."geometry"), ST_MaxY(NEW."geometry")); END;
CREATE TRIGGER "rtree_good_places_geometry_update1" AFTER UPDATE OF "geometry" ON "good_places" WHEN OLD."fid" = NEW."fid" AND (NEW."geometry" NOTNULL AND NOT ST_IsEmpty(NEW."geometry")) BEGIN INSERT OR REPLACE INTO "rtree_good_places_geometry" VALUES (NEW."fid",ST_MinX(NEW."geometry"), ST_MaxX(NEW."geometry"),ST_MinY(NEW."geometry"), ST_MaxY(NEW."geometry")); END;
CREATE TRIGGER "rtree_good_places_geometry_update2" AFTER UPDATE OF "geometry" ON "good_places" WHEN OLD."fid" = NEW."fid" AND (NEW."geometry" ISNULL OR ST_IsEmpty(NEW."geometry")) BEGIN DELETE FROM "rtree_good_places_geometry" WHERE id = OLD."fid"; END;
CREATE TRIGGER "rtree_good_places_geometry_update3" AFTER UPDATE ON "good_places" WHEN OLD."fid" != NEW."fid" AND (NEW."geometry" NOTNULL AND NOT ST_IsEmpty(NEW."geometry")) BEGIN DELETE FROM "rtree_good_places_geometry" WHERE id = OLD."fid"; INSERT OR REPLACE INTO "rtree_good_places_geometry" VALUES (NEW."fid",ST_MinX(NEW."geometry"), ST_MaxX(NEW."geometry"),ST_MinY(NEW."geometry"), ST_MaxY(NEW."geometry")); END;
CREATE TRIGGER "rtree_good_places_geometry_update4" AFTER UPDATE ON "good_places" WHEN OLD."fid" != NEW."fid" AND (NEW."geometry" ISNULL OR ST_IsEmpty(NEW."geometry")) BEGIN DELETE FROM "rtree_good_places_geometry" WHERE id IN (OLD."fid", NEW."fid"); END;
CREATE TRIGGER "rtree_good_places_geometry_delete" AFTER DELETE ON "good_places" WHEN old."geometry" NOT NULL BEGIN DELETE FROM "rtree_good_places_geometry" WHERE id = OLD."fid"; END;
CREATE TRIGGER "trigger_insert_feature_count_good_places" AFTER INSERT ON "good_places" BEGIN UPDATE gpkg_ogr_contents SET feature_count = feature_count + 1 WHERE lower(table_name) = lower('good_places'); END;
CREATE TRIGGER "trigger_delete_feature_count_good_places" AFTER DELETE ON "good_places" BEGIN UPDATE gpkg_ogr_contents SET feature_count = feature_count - 1 WHERE lower(table_name) = lower('good_places'); END;

CREATE TABLE に加えて、CREATE TRIGGER という文がたくさんあります。CREATE TRIGGER は、対象テーブルへの変更をトリガーにして別の SQL を実行する機能です。ここでは、good_places に変更があったら rtree_good_places_geometry にその変更を反映する、というトリガーになっています。では、このテーブルはなんなのでしょうか?

rtree_<テーブル>_<ジオメトリカラム>

これは、空間インデックスを保持するテーブルです。PostGIS のように CREATE INDEX で作るインデックスではなく、テーブルとして持つかたちになっています。

テーブル名は、今回は rtree_good_places_geometry という名前でしたが、一般的には rtree_<テーブル>_<カラム> になります(ジオメトリカラムが複数ある場合は複数の空間インデックスができる)。

これは SQLite の R*Tree module を使って実装されています。スキーマを確認すると、CREATE VIRTUAL TABLE と書かれているのがわかりますが、これは SQLite の virtual table と呼ばれる仕組みです。ユーザーからは通常のテーブルのように見えるけど、実際のディスク上のデータは違う形式になっている、というもので、あんまりよくわかってないんですが、私は「view がもっと何でもありになった版」みたいなものだと思うことにしました。

sqlite> .schema --indent rtree_good_places_geometry
CREATE VIRTUAL TABLE "rtree_good_places_geometry" USING rtree(
  id,
  minx,
  maxx,
  miny,
  maxy
)
/* rtree_good_places_geometry(
  id,
  minx,
  maxx,
  miny,
  maxy
) */;

R*Tree module の場合は、具体的には、3つの別のテーブルに実際のデータが入っていて、それを統合して rtree_<テーブル>_<カラム> として見せている、というものです。

rtree_good_places_geometry_node
rtree_good_places_geometry_parent
rtree_good_places_geometry_rowid

ちなみに、USING rtree(...) には任意のカラムを渡すことができるので、3D のデータに対しては Z 軸のインデックスも加えられそうなものですが、GeoPackage の仕様ではこのインデックスを作成する SQL も明確に指示されていて、X・Y までになっています。

An application that creates a spatial index SHALL create it using the following SQL statement template:

CREATE VIRTUAL TABLE rtree_<t>_<c> USING rtree(id, minx, maxx, miny, maxy)

ジオメトリカラム

さて、元のテーブルに戻ってジオメトリカラムを見てみましょう。仕様の「2.1.3. Geometry Encoding」によれば、ジオメトリカラムは以下のフォーマットのバイト列になっているはずです。

GeoPackageBinaryHeader {
  byte[2] magic = 0x4750; 
  byte version;           
  byte flags;             
  int32 srs_id;           
  double[] envelope;      
}

StandardGeoPackageBinary {
  GeoPackageBinaryHeader header;
  WKBGeometry geometry;          
}

要は、WKB にヘッダが付いた、という形式です。試しにひとつ、実際のバイト列を人力でパースしてみましょう。

sqlite> select hex(geometry) from good_places limit 1;
hex(geometry) = 47500001E61000000101000000A0268D4F4A7D61406D94BC5DD4F24140

magic

4750 0001E61000000101000000A0268D4F4A7D61406D94BC5DD4F24140
^^^^

まず、先頭2バイトには固定で 0x4750 が入りますが、そうなっているようです。

version

4750 00 01E61000000101000000A0268D4F4A7D61406D94BC5DD4F24140
     ^^

次の1バイトは、このバイナリフォーマットの?バージョンです。0はバージョン1という意味らしいです。

flags

475000 01 E61000000101000000A0268D4F4A7D61406D94BC5DD4F24140
       ^^

次の1バイトは、ビットごとに意味が分かれています。

  • 1ビット目:次の srs_id のバイトオーダー(このビットが1なら little endian)
  • 2~4ビット目:次の envelope の形式
  • 5ビット目:ジオメトリが empty かどうか(このビットが1なら empty)
  • 6ビット目:ユーザー定義のジオメトリ型かどうか(このビットが1ならユーザー定義型)
  • 7~8ビット目:未使用

今回は、1ビット目だけが1なので、

  • srs_id のバイトオーダーは little endian
  • envelope はなし
  • ジオメトリは empty ではない
  • ジオメトリ型はユーザー定義型ではなく通常のもの

という情報が含まれていることになります。

srs_id

47500001 E6100000 0101000000A0268D4F4A7D61406D94BC5DD4F24140
         ^^^^^^^^

次の4バイト(32-bit integer)は、SRS ID です。これは little endian に読むのでバイトの並びを逆にして、

00 00 10 E6

つまり 4326 です。WGS84 ですね。

envelope

次は envelope ですが、今回のデータは envelope はないです(たぶんポイントだから?)。envelope がある場合は、ジオメトリの次元によって以下の4種類があるようです。

  • [minx, maxx, miny, maxy](32バイト)
  • [minx, maxx, miny, maxy, minz, maxz](48バイト)
  • [minx, maxx, miny, maxy, minm, maxm](48バイト)
  • [minx, maxx, miny, maxy, minz, maxz, minm, maxm](64バイト)

WKB

47500001E6100000 0101000000A0268D4F4A7D61406D94BC5DD4F24140
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

そして、残りが WKB です。DuckDB で読んでみると、こういうポイントを表しているということで、あってそうです。

D LOAD spatial;
D select ST_GeomFromHEXWKB('0101000000A0268D4F4A7D61406D94BC5DD4F24140');
┌─────────────────────────────────────────────────────────────────┐
│ st_geomfromhexwkb('0101000000A0268D4F4A7D61406D94BC5DD4F24140') │
│                            geometry                             │
├─────────────────────────────────────────────────────────────────┤
│ POINT (139.9153211361745 35.897105900840735)                    │
└─────────────────────────────────────────────────────────────────┘

ということで、以上がジオメトリカラムの情報でした。

gpkg_spatial_ref_sys

名前からちょっと分かりづらいですが、データ中に存在する SRS ID を保持するテーブルのようです。詳細は「1.1.2. Spatial Reference Systems」あたり。

EPSG のものは番号だけで、WKT で定義する場合は definition カラムに入ります。

sqlite> .schema --indent gpkg_spatial_ref_sys
CREATE TABLE gpkg_spatial_ref_sys(
  srs_name TEXT NOT NULL,
  srs_id INTEGER NOT NULL PRIMARY KEY,
  organization TEXT NOT NULL,
  organization_coordsys_id INTEGER NOT NULL,
  definition TEXT NOT NULL,
  description TEXT
);

gpkg_tile_matrix, gpkg_tile_matrix_set

今回はベクタデータなのでなかったですが、ラスタデータの場合はタイル情報がこのテーブルに入るみたいです。詳しくは「2.2. Tiles」を参照。

The gpkg_tile_matrix_set table defines the spatial reference system (srs_id) and the maximum bounding box (min_x, min_y, max_x, max_y) for all possible tiles in a tile pyramid user data table.

tile matrix は、あるズームレベルのタイル情報で、tile matrix set はズームレベル関係なくそのタイルがカバーする範囲の情報、という感じのようです。

"Tile matrix" refers to rows and columns of tiles that all have the same spatial extent and resolution at a particular zoom level.

"Tile matrix set" refers to the definition of a tile pyramid’s tiling structure.

sqlite> .schema --indent gpkg_tile_matrix
CREATE TABLE gpkg_tile_matrix(
  table_name TEXT NOT NULL,
  zoom_level INTEGER NOT NULL,
  matrix_width INTEGER NOT NULL,
  matrix_height INTEGER NOT NULL,
  tile_width INTEGER NOT NULL,
  tile_height INTEGER NOT NULL,
  pixel_x_size DOUBLE NOT NULL,
  pixel_y_size DOUBLE NOT NULL,
  CONSTRAINT pk_ttm PRIMARY KEY(table_name, zoom_level),
  CONSTRAINT fk_tmm_table_name FOREIGN KEY(table_name) REFERENCES gpkg_contents(table_name)
);
CREATE TRIGGER ...(省略)

CREATE TRIGGER は長いので省略しますが、不整合なデータを入れようとすると弾くようなバリデーション処理が記述されています。

sqlite> .schema --indent gpkg_tile_matrix_set
CREATE TABLE gpkg_tile_matrix_set(
  table_name TEXT NOT NULL PRIMARY KEY,
  srs_id INTEGER NOT NULL,
  min_x DOUBLE NOT NULL,
  min_y DOUBLE NOT NULL,
  max_x DOUBLE NOT NULL,
  max_y DOUBLE NOT NULL,
  CONSTRAINT fk_gtms_table_name FOREIGN KEY(table_name) REFERENCES gpkg_contents(table_name),
  CONSTRAINT fk_gtms_srs FOREIGN KEY(srs_id) REFERENCES gpkg_spatial_ref_sys(srs_id)
);

gpkg_contents

このテーブルには、この GeoPackage ファイルに入っているレイヤーの一覧が入っています。詳細は「1.1.3. Contents」あたり。

sqlite> .schema --indent gpkg_contents
CREATE TABLE gpkg_contents(
  table_name TEXT NOT NULL PRIMARY KEY,
  data_type TEXT NOT NULL,
  identifier TEXT UNIQUE,
  description TEXT DEFAULT '',
  last_change DATETIME NOT NULL DEFAULT(strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  min_x DOUBLE,
  min_y DOUBLE,
  max_x DOUBLE,
  max_y DOUBLE,
  srs_id INTEGER,
  CONSTRAINT fk_gc_r_srs_id FOREIGN KEY(srs_id) REFERENCES gpkg_spatial_ref_sys(srs_id)
);

具体的にはこんな感じのデータが入っています。

sqlite> select * from gpkg_contents;
 table_name = good_places
  data_type = features
 identifier = good_places
description =
last_change = 2025-04-05T08:55:39.610Z
      min_x = 139.161193847656
      min_y = 35.2663688659668
      max_x = 140.0927734375
      max_y = 35.8971061706543
     srs_id = 4326

gpkg_geometry_columns

このテーブルには、各レイヤーのジオメトリカラムの情報が入っています。ジオメトリの型は何か、Z座標やM座標があるか、とかとか。詳細は「2.1.5. Geometry Columns」あたり。

sqlite> .schema --indent gpkg_geometry_columns
CREATE TABLE gpkg_geometry_columns(
  table_name TEXT NOT NULL,
  column_name TEXT NOT NULL,
  geometry_type_name TEXT NOT NULL,
  srs_id INTEGER NOT NULL,
  z TINYINT NOT NULL,
  m TINYINT NOT NULL,
  CONSTRAINT pk_geom_cols PRIMARY KEY(table_name, column_name),
  CONSTRAINT uk_gc_table_name UNIQUE(table_name),
  CONSTRAINT fk_gc_tn FOREIGN KEY(table_name) REFERENCES gpkg_contents(table_name),
  CONSTRAINT fk_gc_srs FOREIGN KEY(srs_id) REFERENCES gpkg_spatial_ref_sys(srs_id)
);

具体的にはこんな感じのデータが入っています。

sqlite> select * from gpkg_geometry_columns;
        table_name = good_places
       column_name = geometry
geometry_type_name = POINT
            srs_id = 4326
                 z = 0
                 m = 0

gpkg_extensions

GeoPackage にはいくつか拡張が定義されています。実は、上に書いた R-tree による空間インデックスも拡張で、すべての GeoPackage に含まれるわけではありません。どの拡張が使われているか、という情報が gpkg_extensions に入っています。詳細は「2.3. Extension Mechanism」あたりで、拡張の一覧は「Annex F: Registered Extensions (Normative)」にあります。

sqlite> .schema --indent gpkg_extensions
CREATE TABLE gpkg_extensions(
  table_name TEXT,
  column_name TEXT,
  extension_name TEXT NOT NULL,
  definition TEXT NOT NULL,
  scope TEXT NOT NULL,
  CONSTRAINT ge_tce UNIQUE(table_name, column_name, extension_name)
);

私が作ったデータでは R-tree だけでした。

    table_name = good_places
   column_name = geometry
extension_name = gpkg_rtree_index
    definition = http://www.geopackage.org/spec120/#extension_rtree
         scope = write-only

gpkg_ogr_contents

これは、GeoPackage の仕様にあるものではなく、GDAL が独自に加えているテーブルのようです。シンプルに地物の数が入っています。

sqlite> .schema --indent gpkg_ogr_contents
CREATE TABLE gpkg_ogr_contents(
  table_name TEXT NOT NULL PRIMARY KEY,
  feature_count INTEGER DEFAULT NULL
);

GDAL の GPKG ドライバのページによれば、デフォルトで作成されるようになっています。内部でなにかに使ってるんでしょうか。

ADD_GPKG_OGR_CONTENTS=[YES​/​NO]: Defaults to YES. Defines whether to add a gpkg_ogr_contents table to keep feature count, and associated triggers.

こんな感じで独自のテーブルを勝手に作っちゃうのもありなんですね。

感想

GeoPackage の仕様を初めて読みましたが、「この SQL でテーブルを作成する」「このトリガも一緒に設定する」といったところまで具体的に書かれていて、けっこうかっちり決まってるというか、かなり SQLite の仕組みを前提にしたフォーマットなんだなあと思いました。

ちなみに、なぜこれを調べていたかといえば、Rust で SQLite を書き直すぞ!という OSS があるんですが、ふと「あれ、SQLite ということはこれで GeoPackage が扱える...?」と思ったので、果たしてそんな日は来るのかが気になってしまいました。

https://github.com/tursodatabase/limbo/

今のところ、Limbo にはまだトリガが実装されていないので、少なくとも GeoPackage の writer をつくるのはむりそうです。今後に期待...

MIERUNEのZennブログ

Discussion