7. s2geometry を用いた sf の球面幾何学

Edzer Pebesma and Dewey Dunnington

For a better version of the sf vignettes see https://r-spatial.github.io/sf/articles/

はじめに

この vignette では、球面幾何学が何を意味するのか、そして、パッケージ sf が幾何学的な尺度、述語、変換のために s2geometry ライブラリ (https://s2geometry.io) をどのように使用するかを説明します。 この vignette のコードは、 s2 がインストールされている場合にのみ実行されます。

install.packages("s2")

これで、利用可能です。sf がロードされた後、s2 が使用されているかどうかを確認することができます。

library(sf)
## Linking to GEOS 3.10.3, GDAL 3.5.1, PROJ 7.2.1; sf_use_s2() is TRUE
sf_use_s2()
## [1] TRUE

以下のように s2 パッケージをアタッチします。

library(s2)

sf 関数名は、ほとんど st_ で始まるのと同じで、s2 パッケージの関数は、ほぼ s2_ で始まります。sf 関数は、楕円座標を扱うときに自動的に、おおむね s2 関数を使用します。そうでない場合、例えば st_voronoi() では、次のような警告が表示されます。

Warning message:
In st_voronoi.sfc(st_geometry(x), st_sfc(envelope), dTolerance,  :
  st_voronoi does not correctly triangulate longitude/latitude data

投影座標と地理座標

空間座標には、平面上の点を指す「投影座標」(デカルト座標)と、球面(または楕円体)上の点を指す角度(緯度・経度)を指す「非投影座標」(地理座標)があります。平坦な空間は \(R^2\)、球体は \(S^2\) とも呼ばています。

パッケージ sf は、点 (ノード) と直線 (エッジ) で結ばれたジオメトリを構築するための点、線、および多角形の標準である、simple features を実装しています。シンプルフィーチャの標準は、地理座標を扱うのに適しているかについてはあまり言及していないが、その上に構築された位相的関係系 (DE9-IM) は、2次元平面空間である \(R^2\) を参照している。

しかし、日常的に地理座標を使ってサービスを提供したり、データを交換したりすることが多くなってきています。\(R^2\) を仮定したソフトウェアを使うことで、平面空間はある種の問題には有効です。バージョン 0.9-x までの sf には球形/楕円形の計算をするための関数(面積、長さ、距離の計算、分割のための lwgeom パッケージから)がありましたが、そのような座標で \(R^2\) の平面計算をしていることを

although coordinates are longitude/latitude, st_intersects assumes that they are planar

(メッセージの訳:座標は経度/緯度ですが st_intersects はそれらを平面と見なします)

こういったメッセージで喜んでユーザーに警告していましたし、ユーザーが問題の可能性に対処する責任をほのめかすものでもありました。しかし、このようにすると、例えば、 LINESTRING(-179 0,179 0)

さらには、

こういった点で、あいまいな部分が残ります。

sf バージョン 1.0 以降では、地理的な座標系で空間オブジェクトを提供すると、 sf は球面幾何学のための新しいパッケージ s2 (Dunnington, Pebesma, Rubak 2020) を使用します。これによって、

s2 パッケージは Google によって書かれた C++ s2geometry ライブラリのラッパーで、Google の多くの製品 (例: Google Maps, Google Earth Engine, Bigquery GIS) で使用されており、他のいくつかのプログラミング言語にも翻訳されています。

投影座標系では sf は以前と同様に \(R^2\) で動作します。

基本的な違い

\(R^2\) 上の幾何学や DE9-IM と比較して、s2 パッケージは根本的に新しい概念をいくつかもたらしています。

\(S^2\) 上の多角形は球を2つに分割する

球面 (\(S^2\)) 上では、どんな多角形も2つの領域を定義します。外側の環を追うとき、内側を定義する必要があり、その定義は囲む辺の左側です。これは、多角形を反転させれば(辺の順序を反転させれば)地球儀の他の部分を得ることができ、空の多角形(空集合)に加えて完全な多角形(地球全体)を得ることができることも意味します。

シンプルフィーチャは、リング方向にしたがっています。外側のリングは反時計回り、内側(穴)のリングは時計回りですが、外側のリングと内側のリングの違いはその位置(外側、その後に0個以上の内側)で定義されるので、ある意味でこれは時代遅れです。sf::read_sf には check_ring_dir という引数があり、リングの方向をチェックして修正することができます。間違ったリング方向でも、多くのものは動作します。

\(S^2\) の場合、リングの方向は重要です。そのため、st_as_s2 には oriented = FALSE という引数があり、すべての外側のリングが地球の半分より小さい面積を占めると仮定して、リングの方向をチェックし、修正することができます。

nc = read_sf(system.file("gpkg/nc.gpkg", package="sf")) # リングの方向が間違っている
s2_area(st_as_s2(nc, oriented = FALSE)[1:3]) # リングの方向を直し、面積が正確
## [1] 1137107793  610916077 1423145355
s2_area(st_as_s2(nc, oriented = TRUE)[1:3]) # 方向が間違っている: Earth's surface minus area
## [1] 5.100649e+14 5.100655e+14 5.100646e+14
nc = read_sf(system.file("gpkg/nc.gpkg", package="sf"), check_ring_dir = TRUE)
s2_area(st_as_s2(nc, oriented = TRUE)[1:3]) # 修正は不要
## [1] 1137107793  610916077 1423145355

ここでは、地球全体を表すフルポリゴンとの差分として海を計算した例を紹介します。

g = as_s2_geography(TRUE)
g
## <s2_geography[1]>
## [1] <POLYGON ((0 -90, 0 -90))>

さらに、各国を正射(オルソ画像)投影したものです。

co = s2_data_countries()
oc = s2_difference(g, s2_union_agg(co)) # oceans
b = s2_buffer_cells(as_s2_geography("POINT(-30 52)"), 9800000) # visible half
i = s2_intersection(b, oc) # visible ocean
plot(st_transform(st_as_sfc(i), "+proj=ortho +lat_0=52 +lon_0=-30"), col = 'blue')

(フルポリゴン g の WKT 表示は、シンプルフィーチャ基準に従っていないため有効な WKT ではなく、入力として使えないことに注意してください。フルポリゴンの内部表現を反映しているようで、WKTの拡張が必要なようです。)

これで、海によって覆われた地球表面の割合を計算することができます。

s2_area(oc) / s2_area(g)
## [1] 0.711301

SEMI-OPEN ポリゴンの境界線

s2geometry のポリゴンは、次のようなものがあります。

原理的に、DE9-IM モデルは内部、境界、外部を扱い、交差述語はこれに敏感です(containscovers の違いは全て境界に関するものです)。しかし、DE9-IM は、ポリゴンがポリゴン coverage(重なりはないが、境界は共有)を形成している場合、ポリゴンに点を一意に割り当てることができない。このため、ポリゴン単位で点数をカウントする場合、ある点がポリゴンの境界を共有している場合、その点を見逃すか( contains )、二重にカウントする( coversintersects )ため、偏りが生じることがあり、後処理が必要になることがあります。SEMI-OPEN の非重複ポリゴンを使用すると、すべての点が交差点内の最大で1つのポリゴンに割り当てられることが保証されます。これは、例えば、グリッド(ラスタ)カバレッジにおいて、各グリッドセルが(通常)左上隅とその上下左右を含む場合に、どのように処理されるかに対応します。

a = as_s2_geography("POINT(0 0)")
b = as_s2_geography("POLYGON((0 0,1 0,1 1,0 1,0 0))")
s2_intersects(a, b, s2_options(model = "open")) 
## [1] FALSE
s2_intersects(a, b, s2_options(model = "closed"))
## [1] TRUE
s2_intersects(a, b, s2_options(model = "semi-open")) # a toss
## [1] FALSE
s2_intersects(a, b) # default: semi-open
## [1] FALSE

外接キャップ、外接矩形

sfst_bbox() で行っているような,座標範囲に対する最小値と最大値の計算は,球面座標ではあまり意味がありません。なぜなら、球面空間では、 area covered が必ずしも座標範囲に含まれるとは限らないからです。2つの例を示します。

S2には、外接キャップと外接矩形という2つの選択肢があります。

fiji = s2_data_countries("Fiji")
aa = s2_data_countries("Antarctica")
s2_bounds_cap(fiji)
##        lng       lat    angle
## 1 178.7459 -17.15444 1.801369
s2_bounds_rect(c(fiji,aa))
##     lng_lo    lat_lo    lng_hi    lat_hi
## 1  177.285 -18.28799 -179.7933 -16.02088
## 2 -180.000 -90.00000  180.0000 -63.27066

キャップは、外接キャップ(円)を中点(lat, lng)として、この点を中心とした角度を報告する。外接矩形は、 latlng 座標の _lo_hi の境界を報告します。 フィジーの場合、 lng_lolng_hi よりも大きいということは、その領域が反経線を覆っている(交差している)ことを示していることに注意してください。

S2 と GEOS を切り替え

以前 sf が使用していた 2 次元 \(R^2\) ライブラリは GEOS で、sf は GEOS または s2 を使用するよう設定することができる。まず、s2 がデフォルトで使用されているかどうかを確認します。

sf_use_s2()
## [1] TRUE

以下で、s2 をオフにする(GEOSを使う)ことができます。

sf_use_s2(FALSE)
## Spherical geometry (s2) switched off

以下で、オン(s2 を使用)。

sf_use_s2(TRUE)
## Spherical geometry (s2) switched on

計測

面積

library(sf)
library(units)
## udunits database from /Users/baba/Library/R/x86_64/4.2/library/units/share/udunits/udunits2.xml
nc = read_sf(system.file("gpkg/nc.gpkg", package="sf"))
sf_use_s2(TRUE)
a1 = st_area(nc)
sf_use_s2(FALSE)
## Spherical geometry (s2) switched off
a2 = st_area(nc)
plot(a1, a2)
abline(0, 1)

summary((a1 - a2)/a1)
##       Min.    1st Qu.     Median       Mean    3rd Qu.       Max. 
## -2.638e-04 -1.650e-04 -7.133e-05 -6.448e-05  1.598e-05  2.817e-04

長さ

nc_ls = st_cast(nc, "MULTILINESTRING")
sf_use_s2(TRUE)
## Spherical geometry (s2) switched on
l1 = st_length(nc_ls)
sf_use_s2(FALSE)
## Spherical geometry (s2) switched off
l2 = st_length(nc_ls)
plot(l1 , l2)
abline(0, 1)

summary((l1-l2)/l1)
##       Min.    1st Qu.     Median       Mean    3rd Qu.       Max. 
## -0.0012301 -0.0004396 -0.0001258 -0.0001123  0.0001742  0.0009660

距離

sf_use_s2(TRUE)
## Spherical geometry (s2) switched on
d1 = st_distance(nc, nc[1:10,])
sf_use_s2(FALSE)
## Spherical geometry (s2) switched off
d2 = st_distance(nc, nc[1:10,])
plot(as.vector(d1), as.vector(d2))
abline(0, 1)

summary(as.vector(d1)-as.vector(d2))
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
## -1424.5  -613.2  -236.5  -319.5     0.0   460.1

述語

s2 では、パターン付きの st_relate を除き、すべての単項述語と二項述語が利用可能です。また、 s2 述語を使う場合、 model に応じて、 modelclosed (デフォルト) のときのみ、近傍との交点が報告されます。

sf_use_s2(TRUE)
## Spherical geometry (s2) switched on
st_intersects(nc[1:3,], nc[1:3,]) # 自己交差 + 隣接
## Sparse geometry binary predicate list of length 3, where the predicate
## was `intersects'
##  1: 1, 2
##  2: 1, 2, 3
##  3: 2, 3
sf_use_s2(TRUE)
st_intersects(nc[1:3,], nc[1:3,], model = "semi-open") # 自己交差のみ
## Sparse geometry binary predicate list of length 3, where the predicate
## was `intersects'
##  1: 1
##  2: 2
##  3: 3

変換

s2 の同等品として st_intersectionst_unionst_differencest_sym_difference が利用可能です。N-ary intersection と difference は (まだ) 存在しません。カスケード接続されたユニオンは存在しますが、機能によるユニオンは s2 では機能しません。

バッファ

地理座標を持つフィーチャのバッファは、英国を表す非投影オブジェクトを例にとると、以下のように算出できます。

uk = s2_data_countries("United Kingdom")
class(uk)
## [1] "s2_geography" "s2_xptr"
uk_sfc = st_as_sfc(uk) 
uk_buffer = s2_buffer_cells(uk, distance = 20000)
uk_buffer2 = s2_buffer_cells(uk, distance = 20000, max_cells = 10000)
uk_buffer3 = s2_buffer_cells(uk, distance = 20000, max_cells = 100)
class(uk_buffer)
## [1] "s2_geography" "s2_xptr"
plot(uk_sfc)
plot(st_as_sfc(uk_buffer))
plot(st_as_sfc(uk_buffer2))
plot(st_as_sfc(uk_buffer3))
uk_sf = st_as_sf(uk) 

上のプロットは、s2 バッファ演算の結果の空間精度を max_cells 引数で調整できることを示しています(デフォルトでは1000に設定されています)。適切な値を決めるには、詳細すぎると計算資源が増える (左下の uk_buffer2 で表される) ことと、単純化しすぎる (右下) こととのバランスを取る必要があります。s2で作成されたバッファは、常に s2 のセル境界に沿っており、決して滑らかではないことに注意してください。したがって、 max_cells に大きな数値を選択すると、一見滑らかに見えますが、拡大すると非常に複雑なバッファになります。

同様の結果を得るためには、まず結果を変換してから sfst_buffer() 関数を使用することができます。簡単なベンチマークでは、 s2 ジオメトリエンジンの計算効率を、変換してからバッファを作成する場合と比較して示しています。

# the sf way
system.time({
  uk_projected = st_transform(uk_sfc, 27700)
  uk_buffer_sf = st_buffer(uk_projected, dist = 20000)
})
##    ユーザ   システム       経過  
##      0.121      0.016      0.377
# sf way with few than the 30 segments in the buffer
system.time({
  uk_projected = st_transform(uk_sfc, 27700)
  uk_buffer_sf2 = st_buffer(uk_projected, dist = 20000, nQuadSegs = 4)
})
##    ユーザ   システム       経過  
##      0.037      0.001      0.038
# s2 with default cell size
system.time({
  uk_buffer = s2_buffer_cells(uk, distance = 20000)
})
##    ユーザ   システム       経過  
##      0.036      0.001      0.070
# s2 with 10000 cells
system.time({
  uk_buffer2 = s2_buffer_cells(uk, distance = 20000, max_cells = 10000)
})
##    ユーザ   システム       経過  
##      0.313      0.005      0.325
# s2 with 100 cells
system.time({
  uk_buffer2 = s2_buffer_cells(uk, distance = 20000, max_cells = 100)
})
##    ユーザ   システム       経過  
##      0.003      0.000      0.004

これまでのベンチマークの結果は、地理的解像度と計算機資源の間にトレードオフの関係があることを強調しています。Google Maps などの地理サービスに携わるウェブ開発者がよく理解していることです。この場合、デフォルトの transform -> buffer ワークフローよりもわずかに高速に実行される1000セルのデフォルト設定は、英国を表す入力ジオメトリの低解像度を考えると、おそらく適切なものでしょう。

st_bufferst_is_within_distance か?

sf issue tracker で議論されているように、ワークフローを決定し、地理的解像度のレベルを適切に選択することは、反復プロセスになり得ます。\(R^2\) データに対する GEOS による st_buffer は、滑らかで (ほぼ) 正確です。\(S^2\) データを使用した st_buffer は、より粗く、複雑で、非滑らかであり、チューニングが必要な場合があります。st_bufferが使われる一般的なパターンは以下の通りです。

このような場合、地理座標を扱うのであれば、バッファを計算するのではなく、 st_is_within_distance を直接使用して、 x の各フィーチャに対して、 x から一定の距離 d 以内にある y のすべてのフィーチャを選択することが有効な場合があります。この関数の \(S^2\) 版は空間インデックスを使用するため、大きなデータセットでも高速に処理できます。

参照