Pixel Pedals of Tomakomai

北海道苫小牧市出身の初老の日常

詳解: staticmap

最近、 Rust の staticmap クレートにいくつか PR を送ったので、忘れないように staticmap についてメモっておく。

docs.rs

staticmap とは

事前知識なしに、簡単に地図画像を生成するためのライブラリである。例えば以下は、与えられた緯度経度の列を plot した画像を生成するためのプログラムである。

use std::{env::args, error::Error};

use staticmap::{tools::{CircleBuilder, Color}, StaticMapBuilder};

fn main() -> Result<(), Box<dyn Error>> {
    let mut args = args().skip(1);
    let mut map = StaticMapBuilder::new()
        .width(720)
        .height(480)
        .build()?;

    while let (Some(lat), Some(lon)) = (args.next(), args.next()) {
        let circle = CircleBuilder::new()
        .lat_coordinate(lat.parse::<f64>()?)
        .lon_coordinate(lon.parse::<f64>()?)
        .color(Color::new(true, 255, 0, 0, 255))
        .radius(5.)
        .build()?;
        map.add_tool(circle);
    }

    map.save_png("examples/results/plot_points.png")?;
    Ok(())
}

以下が実行結果である 1 。画像の大きさだけ指定し、生成した StaticMap 型の値 map に緯度経度をひたすらプロットするだけで、いい感じの縮尺で地図を作ってくれる。非常に便利だ。

> cargo run --example plot_points 43.07860134016368 141.34119085145716 33.59907205202905 130.22409863072042 35.71622219061979 139.76355588070456 35.129339438616675 136.09212718953398 35.154769245737306 136.97056270511507 38.27242398344753 141.000820278268 34.82219993583771 135.52507877778308

plot_points.png

OpenStreetMap

staticmapOpenStreetMap の地図データを使っている。地図は四角形の区域ごとに URL で指定される。以下が詳しい。

wiki.openstreetmap.org

結論だけ言ってしまえば、 https://tile.openstreetmap.org/zoom/x/y.png という形式の URL にアクセスすれば、 256×256 のサイズの地図の画像が手に入る。 zoom0 から 18 で、 0 の場合は一枚で世界地図となる。

https://tile.openstreetmap.org/0/0/0.png

xywiki に書かれている方法で計算可能で、メルカトル図法となっている。 0/0 は緯度経度で言えば 85.051100, -180.000000 の地図画像ということになる。地図で言えば、緯度経度の原点 Null Island を真ん中に書いた場合、左上を Slippy Map の (x, y) の原点 (0, 0) としてとることになる。

地図の取得は attohttpcrayon を使って並列に回している。 rayon は並行処理用のライブラリのはずなので、 http client に使うのはいかがなものかと思うのだけど、残念ながら現状の実装では密結合されているため、差し替えることはできない。

tiny-skia

画像の生成には tiny-skia というライブラリを使っている。こちらも密結合されており、差し替えられない。このライブラリは SKIA という Chrome で使われている画像ライブラリの移植らしいが、クラス名などは割と違うので、別途使い方を覚えたほうが良い。

tools

地図上への書き込みは、 tools::* 型を使って行う。 Circle Line などの型があり、これを StaticMapadd_tool 経由で追加することで円や線を書くことができる。 tools とはなっているが、実際は shape に近い概念となっている。

Builder pattern

Rust や Go では常套手段ではあるが、 staticmapBuilder Pattern が使われている。 StaticMaptools::Circle の値が欲しい場合は、対応する Builderbuild メソッドを経由して生成することになる。

座標系

ここから、内部実装の話となる。 staticmap は内部的には 3 つの座標系があり、 staticmap 本体の開発者はこれらを意識する必要がある。

  • lat , lon : 緯度経度
    • lat-90 から 90lon-180 から 180
    • 地図の右方向、 方向が正の方向
    • staticmap の利用者が意識するのはこれだけ
  • x , y : Slippy Map の x y
    • lat , lon だけでは決まらず、 zoom によって変わる
    • 地図の右方向、 方向が正の方向
    • メルカトル図法なので、 y 方向については lat線形ではない
    • f64 の任意の値で良い
      • 地図を得るときに i32 に変換 2 して使う
    • xy0 以上の値を想定するが、最大値も zoom によって決まる
  • px
    • tiny_skia の座標系
    • 左上が原点 (0, 0) で、値はピクセル単位(ただし、型は便利のため f64
    • 地図の右方向、 方向が正の方向
    • 最後の png 画像の描画処理でしか登場しないため、あまり意識しなくて良い

staticmap 開発者は lat, lonx, y の両方を使用するため、これらの違いをきちんと把握しておくことが必要となる。特に Y 座標については上下が逆転するので、例えば min を取る場合に lat では地図の下方側を取ればいいのに対して y については上方側を取らなければならない。

Bounds

bounds.rs に定義されており、このクレートの肝と言える部分である。地図の描画範囲を表す型であり、以下のフィールドで描画範囲は決定される。

  • height width : 生成される png 画像の高さと幅(ピクセル
  • x_center y_center : x, y 座標系における描画の中心点
  • tile_size : Slippy Map における 1 枚のタイルのサイズで、前述した通りほとんどの場合は 256 ピクセル

height widthtile_size で割ってやれば縦横何枚分のタイルが必要かがわかり、 x_center y_center からその枚数分までの範囲が描画されることになる。

Boundszoom も保持しており、これによって tools::* の各図形が持つ lat, lon の座標系を x y に変換することができる。 Slippy Map の tile を得るための URL にも必要である。

一方で、 x_min x_max y_min y_max は Slippy Map のどの範囲を get してくるべきかを表すフィールドだが、他の情報から簡単に計算可能なので、実は不要なフィールドであると言える。

Tool trait

tools/mod.rs に定義されている。 draw に関しては解説不要だろう。察しの通り、 tiny_skia を使って自身地図上に描画する。

問題は extent の方だ。このメソッドは、自身を完全に描画するための範囲を lat, lon の座標系で返す。この値は、地図の zoom や描画範囲を自動で決めるために必要となる。登録された tools::* がすべて画像内に収まるように、 Bounds を生成すればいいのである。

extent の呼び出しは zoom が引数となっていることには注意が必要だ。例えば、 tools.Circleradius は円の半径だが、この値はピクセル単位で指定をする。画像上の 3 pixel が lat lon でどの範囲になるのかは、 zoom によって大きく変わってしまうのだ。

Bounds の生成

save_png が呼び出されたタイミングで、すべての Tool が収まるような範囲 Bounds を生成する。

中心座標 x_center y_center を算出するのは簡単で、 add_tool によって追加されたすべての Toolextent を呼び出し、すべてを収めるための四角形の矩形を lat lon で算出した後に、 x y 座標系に変換してからその四角形の中心を求めれば良い。 lat は最終的に生成される画像に対して線形ではないため、 lat lon のまま中心を求めてはいけないということには注意が必要である 3

zoom を算出するには、総当りする必要がある。 zoom を大きい側 17 から 0 まで順に変化をさせていくと、すべての Tool について extent の呼び出しで求められる範囲を x y 座標で表した場合の四角形の一辺の長さは、逆に小さくなっていく 4 。これと画像の大きさ width height を比較して、四角形が画像内にすべて収まったところでズームアウトをやめると、最適な zoom を決定することができる。具体的には、以下の実装である。

github.com

この方法がうまくいくのは、 lat lon の座標が 2 個以上 add_tool で追加されている場合だけである。 lat lon の座標が 1 個の場合は、 zoom を変化させてもなんら状況は変化しない。極端な例として、半径 radius が 100 pixel の円を縦横 100x100 pixel の画像に描画しようとした場合は、 zoom をどんな値にしても入らないものは入らない。すべての試行に失敗して zoom = 0 を返す。逆に 10 pixel の場合は zoom に関係なく入るので、zoom = 17 だ。

やっかいなのが radius50 にした場合で、理論上はギリギリ画像内に書けるのだが、小数点以下の誤差によって画像内に入ると判定されたり入らないと判定されたり判定がランダムにブレる。その結果、決定される zoom は適当な任意の値となる。


  1. これがなんの plot かは読者への宿題とする。ドラゴンボールの位置ではない。
  2. Slippy Map では 0 ~ zoom によって決まる整数値の間の値しか取れないが、地球は丸いので、任意の実数の x y について、剰余を取るなどして所属する Slippy Map の tile を整数値として算出可能である。
  3. Fix center by hiratara · Pull Request #18 · danielalvsaaker/staticmap · GitHub
  4. Google Maps で北海道を表示してからズームアウトしていけば、ズームアウトすればするほど描画される北海道の大きさは小さくなるだろう。