サーバーサイド・オーバーズーミングのベンチマーク

サーバーサイド・オーバーズーミングについて、データの側面からのコンセプト実証のためのコードを書いてみて、実際に地理院地図　Vector　タイルでベンチマークをとってみたので紹介します。

https://github.com/optgeo/oz-jp

# サーバーサイド・オーバーズーミングの必要性
ArcGIS API for JavaScript は、Mapbox GL JS や MapLibre GL JS 違い、クライアント側でオーバーズーミングをしないため、これを救済するためです。

# oz-jp: サーバーサイド・オーバーズーミングのベンチマーク
## constants.rb
要所を引用すると、次の通りです。

```ruby
LIST_URL = 'https://optgeo.github.io/jp-tile-list/list.csv'
SRC_BASE_URL = 'https://cyberjapandata.gsi.go.jp/xyz/experimental_bvmap'

Z_LOT = 6
Z_SRC = 9
MINZOOM = 10
MAXZOOM = 18
```
`Z_LOT`は、一つのmbtilesファイルを作るタスクの空間的範囲を指定しています。ここでは、z=6のタイルの大きさを一つのタスクの空間的範囲にしています。日本全体を一度に変換してしまおうとすると、タスクが重くなりすぎるため、空間的範囲を定めて分割しています。

通常、私は上記の空間範囲をモジュールと呼ぶことが多いのですが、ここではロット（LOT）と呼んでいます。lotとは、a particular group, collection, or set of people or thingsという意味だそうです（New Oxford America Dictionary）。

`Z_SRC`は、オーバーズーミング元のzです。ここでは、z=9のベクトルタイルをオーバーズーミングしていることを指定しています。

`MINZOOM`は、オーバーズーミングで作るタイルの最小のzです。`MAXZOOM`は、オーバーズーミングで作る最大のzです。

ここでは、z=9のベクトルタイルを引き伸ばして、z=10..18のベクトルタイルを生産する、ということをするという定数の設定をしています。

## Rakefile
```ruby
require './constants'

desc 'produce tiles'
task :tiles do
  system "rm #{DST_DIR}/*.mbtiles" unless CONTINUE 
  sh <<-EOS
rm #{FIFO_DIR}/*; rm #{LOT_DIR}/*; \
curl #{LIST_URL} | grep ^#{Z_LOT}, | shuf | \
parallel --jobs=1 --line-buffer \
ruby produce_lot.rb {}; 
tile-join -o #{MERGED_DIR}/#{Z_SRC}.mbtiles #{LOT_DIR}/*.mbtiles
  EOS
end
```

`curl #{LIST_URL} | grep ^#{Z_LOT}, | shuf |`により、生産するべきロットのタイル番号を引き出して渡しています。`shuf`によってシャッフルするのは、日本の場合特に西側は島嶼地域であり、変換の速度感を把握しにくくなるため、なるべく隅に偏ることなくタスクを試すことが有効であるからです。

`parallel --jobs=1 --line-buffer ruby produce_lot.rb {}` が変換の本体です。ロットのタイル番号を引数として与えながら、ロットごとにオーバーズーミングをバッチ処理していきます。

## produce_lot.rb
メイン処理に当たるところは、次の通りです。

```ruby
dst_path = "#{DST_DIR}/#{LOT_ZXY.join('-')}.mbtiles"
if File.exist?(dst_path)
else
  hooks = charge
  jump_into(LOT_ZXY)
  withdraw(hooks)

  system <<-EOS
tile-join -o #{dst_path} \
#{LOT_DIR}/#{LOT_ZXY.join('-')}*.mbtiles
  EOS
end
```

つまり、コマンドライン引数で渡されたタイル番号から mbtiles のパスを作り、そこにファイルが存在しなければ、`charge` をして `jump_into` をして `withdraw`　をし、最後に `tile-join` でマージをする、という流れです。

### `charge`
これは、レイヤの数だけ Tippecanoe プロセスを用意するものです。Tippecanoe の標準入力につながった名前つきパイプを用意しています。ベクトルタイルはレイヤごとに分かれているのですが、今回の処理で使う `vt2geojson` がレイヤを**指定する**ことでしかレイヤを取り出せないようになっているので、このようにしています。

```ruby
def charge
  hooks = Hash.new
  LAYERS[Z_SRC].each {|layer|
    fifo_path = "#{FIFO_DIR}/#{LOT_ZXY.join('-')}-#{layer}"
    mbtiles_path = "#{LOT_DIR}/#{LOT_ZXY.join('-')}-#{layer}.mbtiles"
    system "rm #{fifo_path}" if File.exist?(fifo_path)
    system "mkfifo #{fifo_path}"
    hooks[layer] = {
      :fifo => File.open(fifo_path, 'w+'),
      :pid => spawn(
        <<-EOS
tippecanoe --force --layer=#{layer} -o #{mbtiles_path} \
--minimum-zoom=#{MINZOOM} --maximum-zoom=#{MAXZOOM} \
--detect-shared-borders --coalesce --hilbert \
< #{fifo_path} 2>&1
        EOS
      )
    }
  }
  hooks
end
```

### `jump_into`
引数として渡されるタイル番号を解釈し、それが読み込むべきzであれば`process`を呼び出し、そうでなければ、そのタイル番号に包含される4つのタイルについて`jump_into`を呼び出します。これは典型的な再帰的処理だ、と私は思います。

```ruby
def jump_into(zxy)
  if zxy[0] == Z_SRC
    process(zxy)
  else
    2.times {|i|
      2.times {|j|
        jump_into([
          zxy[0] + 1,
          2 * zxy[1] + i,
          2 * zxy[2] + j
        ])
      }
    }
  end
end
```
`withdraw`を見る前に、この`jump_into`から呼び出される`process`を見てみます。

### `process`
読み込むべきタイル番号が与えられて呼び出される関数です。タイル番号から　　URL を作り、実際にタイルをダウンロードして、正常に読み込まれれば、（go言語で書かれた方の）[vt2geojson](https://github.com/wangyoucao577/vt2geojson)でGeoJSONに展開して、名前つきパイプ経由でTippecanoeにフィードします。

```ruby
def process(zxy)
  print "process #{zxy} in #{LOT_ZXY}\n"
  tile_path = "#{TMP_DIR}/#{zxy.join('-')}.pbf"
  status = `curl #{SRC_BASE_URL}/#{zxy.join('/')}.pbf -o #{tile_path} -w '%{http_code}' -s`
  if status == '200'
    LAYERS[Z_SRC].each {|layer|
      fifo_path = "#{FIFO_DIR}/#{LOT_ZXY.join('-')}-#{layer}"
      system <<-EOS
#{VT2GEOJSON_PATH} -gzipped=false -mvt #{tile_path} -layer #{layer} \
-z #{zxy[0]} -x #{zxy[1]} -y #{zxy[2]} \
| tippecanoe-json-tool > #{fifo_path}
      EOS
    }
  end
  system "rm #{tile_path}"
end
```

### `withdraw`
処理するべき全てのタイルについて `process` された後で呼び出される関数です。名前つきパイプを閉じることで、Tippecanoeによるタイル生産を起動させ、Tippecanoeの終了を待ってから名前付きパイプを削除します。

```ruby
def withdraw(hooks)
  LAYERS[Z_SRC].each {|layer|
    fifo_path = "#{FIFO_DIR}/#{LOT_ZXY.join('-')}-#{layer}"
    print "Producing vector tiles for #{fifo_path} (pid #{hooks[layer][:pid]})...\n"
    $stdout.flush
    hooks[layer][:fifo].flush
    hooks[layer][:fifo].close
    Process.waitpid(hooks[layer][:pid])
    system "rm #{fifo_path}"
    print "done (#{fifo_path}, pid #{hooks[layer][:pid]}).\n"
  }
end
```

# 計測値
実際に変換をしてみて、えられた mbtiles の内部のサイズ分布を `vt-optimizer` で分析した結果を共有します。この作業での私による有効数字の扱いはいい加減で、vt-optimizerの表示を MB や GB に変換する際には、1MB=1000kB, 1GB=1000000kBというようなざっくりした読み替えをしています。

また、実際の変換は、USB HDDを接続した Raspberry Pi 4B で実施しています。

##　z=12を用いたz=16までのオーバーズーミング
| z |　タイル数 | 総容量 | 平均容量 | 最大容量 |
|:-:|:-:|:-:|:-:|:-:|
| 13 | 9万 | 95MB | 1KB | 12KB |
| 14 | 36万 | 149MB | 0.4KB | 7KB |
| 15 | 139万 | 294MB | 0.2KB | 4KB |
| 16 | 542万 | 747MB | 0.1KB | 3KB |

## z=10を用いたz=16までのオーバーズーミング
| z |　タイル数 | 総容量 | 平均容量 | 最大容量 |
|:-:|:-:|:-:|:-:|:-:|
| 11 | 1.7万 | 18MB | 1KB | 34KB |
| 12 | 6.4万 | 25MB | 0.4KB | 13KB |
| 13 | 25万 | 46MB | 0.2KB | 5KB |
| 14 | 99万 | 115MB | 0.1KB | 4KB |
| 15 | 392万 | 358MB | 0.1KB | 3KB |
| 16 | 1531万 | 1.3GB | 0.1KB | 2KB |

## z=9を用いたz=18までのオーバーズーミング
| z |　タイル数 | 総容量 | 平均容量 | 最大容量 |
|:-:|:-:|:-:|:-:|:-:|
| 10 | 4千 | 8MB | 1.9KB | 45KB |
| 11 | 1.7万 | 10MB | 0.6KB | 19KB |
| 12 | 6.4万 | 17MB | 0.3KB | 7KB |
| 13 | 25万 | 35MB | 0.1KB | 3KB |
| 14 | 99万 | 99MB | 0.1KB | 3KB |
| 15 | 390万 | 330MB | 0.09KB | 1KB |
| 16 | 1500万 | 1.2GB | 0.08KB | 1KB |
| 17 | 5900万 | 3.5GB | 0.08KB | 0.7KB |
| 18 | 2.3億 | 18GB | 0.08KB | 0.5KB |

ズームレベル18までの生産をすると、上記のとおり膨大なストレージを使うことになります。mbtiles の集約にも、実時間で50時間以上を使ってしまいました。工夫の余地はあると思いますが、工夫をしなければ現実的なソリューションを得ることが難しそうです。

## z=15を用いたz=18までのオーバーズーミング (in progress)

| z |　タイル数 | 総容量 | 平均容量 | 最大容量 |
|:-:|:-:|:-:|:-:|:-:|
| 16 | * | * | * | * |
| 17 | * | * | * | * |
| 18 | * | * | * | * |


# 現時点での感想
必要に迫られてやむなく1, 2ズームレベル分のオーバーズーミングをすることは否定しないとはいえ、ArcGIS API for JavaScript救済が唯一の目的である本件に、かけられるコストの余裕はそれほどないように感じています。