サーバーサイド・オーバーズーミングについて、データの側面からのコンセプト実証のためのコードを書いてみて、実際に地理院地図 Vector タイルでベンチマークをとってみたので紹介します。
サーバーサイド・オーバーズーミングの必要性
ArcGIS API for JavaScript は、Mapbox GL JS や MapLibre GL JS 違い、クライアント側でオーバーズーミングをしないため、これを救済するためです。
oz-jp: サーバーサイド・オーバーズーミングのベンチマーク
constants.rb
要所を引用すると、次の通りです。
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
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
メイン処理に当たるところは、次の通りです。
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
がレイヤを指定することでしかレイヤを取り出せないようになっているので、このようにしています。
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
を呼び出します。これは典型的な再帰的処理だ、と私は思います。
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でGeoJSONに展開して、名前つきパイプ経由でTippecanoeにフィードします。
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の終了を待ってから名前付きパイプを削除します。
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救済が唯一の目的である本件に、かけられるコストの余裕はそれほどないように感じています。