静的な STAC カタログを stac-ruby でつくる

この記事では、[stac-ruby](https://github.com/sankichi92/stac-ruby) による静的な [STAC](https://stacspec.org/) カタログのつくり方を紹介します。

データとして使用するのは、[国土交通省国土数値情報ダウンロードサイトの行政区域データ](https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-N03-v3_1.html)です。
こちらを元に、日本全国の市区町村の行政区域界を STAC カタログ化する Ruby スクリプトを作成します。

生成した STAC カタログは、https://jp-ksj-n03-stac.sankichi.app/ で公開しています。
[STAC Browser](https://github.com/radiantearth/stac-browser) を使用すれば、視覚的にわかりやすくブラウジングできます。
次の URL から確認してみてください: https://radiantearth.github.io/stac-browser/#/external/jp-ksj-n03-stac.sankichi.app/

# STAC とは

STAC は SpatioTemporal Asset Catalogs の略で、地理空間と時間をメタデータにもつ（ラスタ）データを検索・クロールできるようにするための規格です。

[FOSS4G 2022 Japan Online](https://www.osgeo.jp/events/foss4g-2022/foss4g-2022-japan-online) の発表で使用した次のスライドで概要を紹介しています。
この記事では以下、スライドの内容を前提知識として進めます。

https://speakerdeck.com/sankichi92/foss4gj-2022-stac-introduction

より詳しくは公式ページ https://stacspec.org/ を参照ください。

また、今回つくるのは、単純に JSON ファイルを配信するだけの「静的な」STAC カタログです。
静的なカタログと動的なカタログの違いについて詳しくは、[STAC の Best Practices](https://github.com/radiantearth/stac-spec/blob/v1.0.0/best-practices.md#static-and-dynamic-catalogs) を参照ください。

# データの紹介

冒頭でも紹介したとおり、行政区域データ、つまり、市区町村の境界を表現したポリゴンを STAC カタログ化します。

オリジナルのデータは次のページで提供されています。

https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-N03-v3_1.html

この記事の目的は STAC カタログをつくることなので、データは何でもよかったのですが、以下の理由からこちらを選びました。

- オープンデータ（CC-BY-4.0 互換）である
- 国土数値情報ダウンロードサイトで都道府県・年毎に分けられており、地理空間と時間をメタデータにもつ
- 2018年以降は FeatureCollection 型の GeoJSON が提供されており、Feature 型の GeoJSON の拡張である STAC Item へ簡単に変換できる
- 市区町村の境界は一般にも馴染みがあり、データそのものの理解が容易である
- （筆者自身が過去に使用したことがある）

ただし、STAC は本来ラスタデータのためのものであり、ベクタデータに使用することは [STAC の Best Practices で推奨されていません](https://github.com/radiantearth/stac-spec/blob/v1.0.0/best-practices.md#representing-vector-layers-in-stac)。
STAC の優位性を示すためにもラスタデータを対象にできればよかったのですが、面倒な前処理なしに「STAC カタログのつくり方」に集中できるちょうどよい題材が思い浮かばず、ベクタデータで代用することにしました。

# カタログの設計

STAC カタログをつくるには、コードを書く前にまず設計が必要です。
ある程度は STAC の仕様で規定されていますが、特に以下のような点は実装者が考えなければなりません。

- Catalog や Collection の粒度と階層構造
- ID 等カタログ横断的な考慮が必要な STAC 固有のメタデータの内容
- Required でない仕様の対応有無
- [Extensions](https://github.com/radiantearth/stac-spec/tree/v1.0.0/extensions) の利用有無

最後の2点について、今回はできるだけシンプルな STAC カタログを作成するため、Required でないフィールドの対応は最小限とし、Extensions は利用しないものとします。

## 階層設計

階層設計に関しては [Best Practices の Catalog Loyout](https://github.com/radiantearth/stac-spec/blob/v1.0.0/best-practices.md#catalog-layout) でも指針が示されています。

行政区域データはもともと年・都道府県ごとに分割されているので、以下のような階層構造が候補として考えられます。

まず、`Root / 都道府県 / 年 / 市区町村 (items)` のパターン:

```
Root
├── 北海道
│   ├── 2021
│   │   ├── 札幌市中央区
│   │   ├── 札幌市北区
│   │   └── ……
│   └── 2022
│       ├── 札幌市中央区
│       ├── 札幌市北区
│       └── ……
├── 青森県
│   ├── 2021
│   │   └── ……
│   └── 2022
│       └── ……
└── ……
```

次に、`Root / 年 / 都道府県 / 市区町村 (items)` のパターン:

```
Root
├── ……
├── 2021
│   ├── 北海道
│   │   ├── 札幌市中央区
│   │   ├── 札幌市北区
│   │   └── ……
│   └── 青森県
│       ├── 青森市
│       ├── 弘前市
│       └── ……
└── 2022
    ├── 北海道
    │   └── ……
    └── 青森県
        └── ……
```

また、都道府県と年をフラットにして階層を減らす `Root / 年-都道府県 / 市区町村 (items)` パターンも考えられます:

```
Root
├── ……
├── 2021-北海道
│   ├── 札幌市中央区
│   ├── 札幌市北区
│   └── ……
├── 2021-青森県
│   ├── 青森市
│   ├── 弘前市
│   └── ……
├── ……
├── 2022-北海道
│   └── ……
└── 2022-青森県
    └── ……
```

いずれのパターンでも問題ありませんが、ここでは階層がもっとも浅い最後のパターンを採用します。
`Catalog / Collection / Item` と STAC オブジェクトを1種類ずつ紹介できるので例示にちょうどよいためです。

## ID 設計

データを一意に識別できる ID はカタログにとって非常に重要です。
ID 設計については、[Collections の仕様](https://github.com/radiantearth/stac-spec/blob/master/collection-spec/collection-spec.md#id) や [Best Practices](https://github.com/radiantearth/stac-spec/blob/v1.0.0/best-practices.md#item-ids) で以下のような指針が示されています。

- 少なくとも同一提供者の中でユニーク、できればグローバルでユニークにする
- URL に含まれるため、小文字英数字と `-`、`_` からなるようにする
- 検索に使用できる意味のある ID にする

まず、ルートとなる Catalog ID から考えます。
国土数値情報ダウンロードサイトで行政区域データは「N03」という識別子が与えられています。
ただ、「N03」だけではグローバルでユニークにはならなそうなので、prefix に日本の国土数値情報であることを付与して小文字化し、`jp-ksj-n03` とします。
（KSJ は国土数値情報ダウンロードサイトの URL に使用されており、おそらく Kokudo Suuchi Jouhou の略です。）

続いて、2階層目 `年-都道府県` に対応する Collection ID です。
国土数値情報ダウンロードサイトで提供されるファイル名は `N03-#{年}0101_#{都道府県コード}_GML.zip` となっていました。
これを参考に、また、グローバルで一意となるようルートの Catalog ID をそのまま prefix として、`jp-ksj-n03-#{年}0101-#{都道府県コード}` のようにすることとします。

最後に、各市区町村に対応する Item ID です。
これは、Collection ID をベースに市区町村の行政区域コードを使い、`jp-ksj-n03-#{年}0101-#{行政区域コード}` とします（行政区域コードに都道府県コードが含まれます）。

# カタログの作成

設計ができたところで、国土数値情報ダウンロードサイトのデータを STAC カタログに変換するコードを書いていきます。
タイトルの通り、筆者の開発した stac-ruby という Ruby gem を使用します（開発にあたっては [PySTAC](https://github.com/stac-utils/pystac) を参考にしました）。

https://github.com/sankichi92/stac-ruby

以下のコマンドでインストールしてください。

```console
$ gem install stac
```

また、国土数値情報ダウンロードサイトのデータの取得や前処理は本記事のスコープ外とするため、それらを隠蔽した `KSJN03` というクラスを導入します。
このクラスはコンストラクタ引数に年 `year` と都道府県コード `pref_code` を受け取り、`#zip_url` と `#extract_shikuchoson_features` という2つのメソッドを持ちます。

```ruby
require_relative 'ksj_n03' # 同じディレクトリに `ksj_n03.rb` を配置しておく

ksjn03 = KSJN03.new(year: 2022, pref_code: '01') # 01 は北海道

# オリジナルデータの URL を返す
ksjn03.zip_url #=> "https://nlftp.mlit.go.jp/ksj/gml/data/N03/N03-2022/N03-20220101_01_GML.zip"

# ZIP をダウンロードして GeoJSON を抽出、市区町村ごとの GeoJSON Feature を前処理し、その配列を返す
ksjn03.extract_shikuchoson_features
```

前処理の内容や具体的な実装は https://github.com/sankichi92/stac-jp-ksj-n03/blob/main/ksj_n03.rb を参照ください。

## Root Catalog

まず、ルートとなる Catalog オブジェクトを作成します。
`rel="root"` な link を自分自身に設定した Catalog を返す `STAC::Catalog.root` というメソッドがあるのでこれを使用します。

```ruby
require 'stac'
require_relative 'ksj_n03' # 同じディレクトリに `ksj_n03.rb` を配置しておく

catalog_id = 'jp-ksj-n03'
catalog = STAC::Catalog.root(
  id: catalog_id,
  title: '日本の行政区域界',
  description: '日本の行政区域界のSTACカタログ。年・都道府県ごとにコレクションを分けている。',
  href: 'https://jp-ksj-n03-stac.sankichi.app/index.json' # rel="self" な link をこの値で追加する
)

pp catalog
# {"type"=>"Catalog",
#  "stac_version"=>"1.0.0",
#  "links"=>[{"rel"=>"self", "href"=>"https://jp-ksj-n03-stac.sankichi.app/index.json", "type"=>"application/json"}, {"rel"=>"root", "href"=>"https://jp-ksj-n03-stac.sankichi.app/index.json", "type"=>"application/json", "title"=>"日本の行政区域界"}],
#  "id"=>"jp-ksj-n03",
#  "title"=>"日本の行政区域界",
#  "description"=>"日本の行政区域界のSTACカタログ。年・都道府県ごとにコレクションを分けている。"}
```

## Collection

Collection オブジェクトは年 `year` と都道府県コード `pref_code` ごと作成します。

Collection オブジェクトは `STAC::Collection.from_hash` で作成し、`STAC::Catalog#add_child` で Catalog オブジェクトに追加します。
各フィールドの意味は [Collection の仕様](https://github.com/radiantearth/stac-spec/blob/v1.0.0/collection-spec/collection-spec.md) を参照してください。

```ruby
year = 2022
pref_code = '01'

ksjn03 = KSJN03.new(year:, pref_code:)
features = ksjn03.extract_shikuchoson_features
pref_name = features.first['properties']['N03_001'] # N03_001 は都道府県名

collection = STAC::Collection.from_hash(
  type: 'Collection',
  stac_version: '1.0.0',
  id: "#{catalog_id}-#{year}0101-#{pref_code}",
  title: "#{year} #{pref_name}", #=> "2022 北海道"
  description: "#{year}年#{pref_name}の行政区域界コレクション。",
  extent: {
    spatial: {
      bbox: [
        # 市区町村の bbox の最大範囲を Collection の bbox として計算
        features.map { |f| f['bbox'] }.inject([180, 90, -180, -90]) do |res, bbox|
          [[res[0], bbox[0]].min, [res[1], bbox[1]].min, [res[2], bbox[2]].max, [res[3], bbox[3]].max]
        end
      ]
    },
    temporal: {
      interval: [[Time.new(year).utc.iso8601, (Time.new(year + 1) - 1).utc.iso8601]] #=> [["2022-01-01T00:00:00Z", "2022-12-31T23:59:59Z"]]
    }
  },
  license: 'CC-BY-4.0', # https://nlftp.mlit.go.jp/ksj/other/agreement.html#agree-01
  providers: [
    {
      name: '国土交通省',
      description: '国土数値情報（行政区域データ）',
      roles: %w[licensor producer],
      url: 'https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-N03-v3_1.html'
    },
    {
      name: '@sankichi92',
      description: 'STACカタログへの加工とホスティングを実施',
      roles: %w[processor host],
      url: 'https://github.com/sankichi92'
    }
  ]
)

# rel="child" な link として作成した collection を catalog に追加する
# collection 側にも rel="self", rel="root", rel="parent" の link が追加される
catalog.add_child(collection)
```

## Item

行政区域界の GeoJSON Feature を STAC Item に変換します。

STAC Item に必要なフィールドを GeoJSON の Hash に `Hash#merge` で追加して `STAC::Item.from_hash` に渡し、Item オブジェクトを作成します。
`properties` フィールドの `datetime` は必須です。
`assets` フィールドには本来対応するラスタデータを追加しますが、今回は加工元データを追加しています。

```ruby
features.each do |feature|
  item = STAC::Item.from_hash(
    feature.merge(
      'id' => "#{catalog_id}-#{year}0101-#{feature['properties']['N03_007']}",
      'properties' => feature['properties'].merge(
        'title' => "#{feature['properties']['N03_003']}#{feature['properties']['N03_004']}",
        'datetime' => Time.new(year).utc.iso8601
      ),
      'assets' => {
        'data' => {
          'title' => '加工元データ',
          'description' => "加工元となった#{year}年#{pref_name}の行政区域データ。GML、Shapefile、GeoJSON を含む ZIP ファイル。",
          'href' => ksjn03.zip_url,
          'type' => 'application/zip',
          'roles' => %w[data]
        }
      }
    )
  )

  collection.add_item(item)
end
```

## ファイルの書き出し

ここまでで STAC カタログがメモリに乗った状態になりました。

これをファイルに書き出すには `STAC::Catalog#export` を使用します。
引数に出力先のディレクトリのパスを渡します。

```ruby
catalog.export('output')
```

すると、`output` 以下に [Best Practices の Catalog Loyout](https://github.com/radiantearth/stac-spec/blob/v1.0.0/best-practices.md#catalog-layout) の命名に沿った STAC カタログが書き出されます。

```console
$ tree output
output
├── index.json
└── jp-ksj-n03-20220101-01
    ├── collection.json
    ├── jp-ksj-n03-20220101-01101.json
    ├── jp-ksj-n03-20220101-01102.json
    ├── jp-ksj-n03-20220101-01103.json
    ├── ……
```

しかし、中身を確認すると、Ruby の BigDecimal 型で格納されていた緯度経度が指数表現の文字列として出力されていました。
たとえば、`output/jp-ksj-n03-20220101-01/collection.json` の bbox フィールドは次のようになってしまっています。

```jsonc
{
  // ...
  "extent": {
    "spatial": {
      "bbox": [
        "0.139333960169026795e3",
        "0.41351645558995529e2",
        "0.148894403190854746e3",
        "0.45557243413952563e2"
      ]
    },
    // ...
  },
  // ...
}
```

これを解消するため、BigDicimal 型の JSON 出力について、以下の対応を追加します。

- [active_support/core_ext/big_decimal/conversions](https://github.com/rails/rails/blob/main/activesupport/lib/active_support/core_ext/big_decimal/conversions.rb) で指数を使わないフォーマットで出力
- [Oj](https://github.com/ohler55/oj) gem で String ではなく Number として出力

```ruby
require 'active_support/core_ext/big_decimal/conversions'
require 'oj'

catalog.export(
  'output',
  writer: STAC::FileWriter.new(hash_to_json: ->(hash) { Oj.dump(hash) })
)
```

また、現状のコードでは2022年の北海道しか出力されません。
2018年以降の各年について47都道府県を出力するためには、Collection と Item の作成を以下のブロック内で実行するようにします。

```ruby
years = [2018, 2019, 2020, 2021, 2022]
pref_codes = (1..47).map { |i| i.to_s.rjust(2, '0') } #=> ["01", "02", "03", ..., "47"]

years.product(pref_codes).do |year, pref_code|
  # 各年・各都道府県について Collection、Item を作成、カタログに追加
end
```

https://jp-ksj-n03-stac.sankichi.app/ の生成に使用しているコードは以下のリポジトリにあります。

https://github.com/sankichi92/stac-jp-ksj-n03

# カタログの検証

作成したカタログが STAC の仕様を満たしているかは、[stac-validator](https://github.com/stac-utils/stac-validator) で確認できます。

```console
$ pip install stac-validator
```

ローカルで確認する場合は、`STAC::Item.root` に渡す `href` の値を相対パスにしてください。

```diff
 catalog = STAC::Catalog.root(
   id: catalog_id,
   title: '日本の行政区域界',
   description: '日本の行政区域界のSTACカタログ。年・都道府県ごとにコレクションを分けている。',
-  href: 'https://jp-ksj-n03-stac.sankichi.app/index.json'
+  href: './index.json'
 )
```

相対パスで STAC カタログを出力し直したら、以下を実行して仕様を満たしているかの validation を実施します。

```console
$ stac-validator output/index.json --recursive
```

# カタログのデプロイ

今回作成したのは静的な STAC カタログなので、ローカルに書き出したファイルをそのまま Web サーバや Amazon S3 のようなクラウドストレージ等に配置して配信するようにすればデプロイ完了です。

https://jp-ksj-n03-stac.sankichi.app/ のコンテンツは GitHub Pages でホストしており、GitHub Actions でカタログ生成とデプロイを行うようにしています。
workflow の設定は https://github.com/sankichi92/stac-jp-ksj-n03/blob/main/.github/workflows/build-and-deploy.yml を参照してください。
（素朴に実行したところ、おそらくメモリが足りなくて途中でエラーになったので、2018年と2022年だけを対象にするようにしました。）

---

以上、stac-ruby による静的な STAC カタログのつくり方でした。
地理空間と時間をメタデータにもつラスタデータをお持ちでしたらぜひ STAC カタログにして公開してみてください！