S2 cell hierarchy in Python
Hierarchical decomposition of the sphere into quadrilateral cells indexed along a Hilbert curve.
- File
s2_cell_hierarchy.py, 170 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/s2-cell-hierarchy/. The authors are listed in prov/MMDM7B.jsonl.
"""S2 cell ids: a hierarchy of cells on the sphere, numbered along a Hilbert curve.
The sphere is projected onto the six faces of a cube. Each face is a
quadtree of 30 levels. A cell is named by a 64-bit id: 3 bits for the
face, then 2 bits for each level, which give the position of the child
along a Hilbert curve, then a 1 bit, then zeros. The parent of a cell is
its id with the last two position bits cleared and the 1 bit moved up.
This file follows the conventions of the S2 library (face numbering, the
quadratic projection, and the Hilbert tables); it is a small model, not
the library.
"""
from __future__ import annotations
import math
from typing import List, Tuple
MAX_LEVEL = 30
POS_BITS = 2 * MAX_LEVEL + 1 # 61
MAX_SIZE = 1 << MAX_LEVEL
SWAP, INVERT = 1, 2
# Hilbert tables of the S2 library: for an orientation, the position (0..3)
# of the child with bits ij = (i << 1) | j, and the change of orientation
# for each position.
IJ_TO_POS = ((0, 1, 3, 2), (0, 3, 1, 2), (2, 3, 1, 0), (2, 1, 3, 0))
POS_TO_IJ = ((0, 1, 3, 2), (0, 2, 3, 1), (3, 2, 0, 1), (3, 1, 0, 2))
POS_TO_ORIENTATION = (SWAP, 0, 0, INVERT | SWAP)
def latlng_to_xyz(lat_deg: float, lng_deg: float) -> Tuple[float, float, float]:
lat, lng = math.radians(lat_deg), math.radians(lng_deg)
return (math.cos(lat) * math.cos(lng), math.cos(lat) * math.sin(lng), math.sin(lat))
def xyz_to_face_uv(p: Tuple[float, float, float]) -> Tuple[int, float, float]:
x, y, z = p
ax, ay, az = abs(x), abs(y), abs(z)
face = 0 if ax >= ay and ax >= az else (1 if ay >= az else 2) # @line L1
if p[face] < 0:
face += 3
if face == 0:
u, v = y / x, z / x
elif face == 1:
u, v = -x / y, z / y
elif face == 2:
u, v = -x / z, -y / z
elif face == 3:
u, v = z / x, y / x
elif face == 4:
u, v = z / y, -x / y
else:
u, v = -y / z, -x / z
return face, u, v
def face_uv_to_xyz(face: int, u: float, v: float) -> Tuple[float, float, float]:
return [(1, u, v), (-u, 1, v), (-u, -v, 1), (-1, -v, -u), (v, -1, -u), (v, u, -1)][face]
def uv_to_st(u: float) -> float:
"""The quadratic projection of S2, which makes cells of one level closer in area."""
return 0.5 * math.sqrt(1 + 3 * u) if u >= 0 else 1 - 0.5 * math.sqrt(1 - 3 * u) # @line L2
def st_to_uv(s: float) -> float:
return (4 * s * s - 1) / 3 if s >= 0.5 else (1 - 4 * (1 - s) * (1 - s)) / 3
def st_to_ij(s: float) -> int:
return max(0, min(MAX_SIZE - 1, int(math.floor(MAX_SIZE * s)))) # @line L3
def from_face_ij(face: int, i: int, j: int) -> int:
"""The leaf cell id (level 30) of the cell (i, j) of a face."""
orientation = face & SWAP # @line L4
pos = 0
for k in range(MAX_LEVEL - 1, -1, -1): # @line L5
ij = (((i >> k) & 1) << 1) | ((j >> k) & 1)
p = IJ_TO_POS[orientation][ij] # @line L6
pos = (pos << 2) | p
orientation ^= POS_TO_ORIENTATION[p] # @line L7
return (face << POS_BITS) | (pos << 1) | 1 # @line L8
def from_latlng(lat: float, lng: float, level: int = MAX_LEVEL) -> int:
face, u, v = xyz_to_face_uv(latlng_to_xyz(lat, lng))
leaf = from_face_ij(face, st_to_ij(uv_to_st(u)), st_to_ij(uv_to_st(v)))
return parent(leaf, level)
def lsb(cid: int) -> int:
return cid & -cid
def level(cid: int) -> int:
return MAX_LEVEL - (lsb(cid).bit_length() - 1) // 2
def face(cid: int) -> int:
return cid >> POS_BITS
def is_valid(cid: int) -> bool:
return 0 < cid < (6 << POS_BITS) and (lsb(cid).bit_length() - 1) % 2 == 0
def parent(cid: int, lev: int) -> int:
"""The ancestor of cid at level lev (lev <= level(cid))."""
if not 0 <= lev <= level(cid):
raise ValueError("bad level")
new_lsb = 1 << (2 * (MAX_LEVEL - lev)) # @line L9
return (cid & -new_lsb) | new_lsb # @line L10
def children(cid: int) -> List[int]:
"""The four children, in Hilbert order."""
if level(cid) == MAX_LEVEL:
raise ValueError("a leaf cell has no children")
b = lsb(cid)
nb = b >> 2 # @line L11
first = cid - b + nb
return [first + 2 * nb * k for k in range(4)] # @line L12
def range_min(cid: int) -> int:
return cid - (lsb(cid) - 1)
def range_max(cid: int) -> int:
return cid + (lsb(cid) - 1)
def contains(a: int, b: int) -> bool:
"""Cell a contains cell b: b lies in the id range of the leaves of a."""
return range_min(a) <= b <= range_max(a) # @line L13
def to_face_ij(cid: int) -> Tuple[int, int, int, int]:
"""Face, i, j of the lower left leaf of cid, and the orientation there."""
f = face(cid)
orientation = f & SWAP
leaf = range_min(cid)
pos = (leaf >> 1) & ((1 << (2 * MAX_LEVEL)) - 1)
i = j = 0
for k in range(MAX_LEVEL - 1, -1, -1):
p = (pos >> (2 * k)) & 3
ij = POS_TO_IJ[orientation][p]
i = (i << 1) | (ij >> 1)
j = (j << 1) | (ij & 1)
orientation ^= POS_TO_ORIENTATION[p]
return f, i, j, orientation
def ij_bounds(cid: int) -> Tuple[int, int, int, int]:
"""The face and the range of leaf i and j that the cell covers: (face, i0, j0, size)."""
f, i, j, _ = to_face_ij(cid)
size = 1 << (MAX_LEVEL - level(cid))
return f, i & -size, j & -size, size
def token(cid: int) -> str:
"""The hex form of the id without trailing zeros, as in S2."""
h = "%016x" % cid
return h.rstrip("0") or "X"
def from_token(tok: str) -> int:
return int(tok.ljust(16, "0"), 16)
test_s2_cell_hierarchy.py 87 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/s2-cell-hierarchy/. The authors are listed in prov/MMDM7B.jsonl.
import random
import pytest
from s2_cell_hierarchy import (MAX_LEVEL, MAX_SIZE, children, contains, face, face_uv_to_xyz,
from_face_ij, from_latlng, from_token, ij_bounds, is_valid,
latlng_to_xyz, level, lsb, parent, range_max, range_min,
st_to_ij, st_to_uv, to_face_ij, token, uv_to_st, xyz_to_face_uv)
FACE = [(f << 61) | (1 << 60) for f in range(6)]
def test_page_example():
c = from_latlng(40.7128, -74.0060) # New York
assert face(c) == 4 and level(c) == 30
assert token(c).startswith("89c25")
assert [token(parent(c, k)) for k in (0, 1, 2)] == ["9", "8c", "89"]
assert token(from_latlng(0, 0)) == "1000000000000001"
kids = children(parent(c, 0))
assert [token(x) for x in kids] == ["84", "8c", "94", "9c"]
assert contains(kids[1], c) and not contains(kids[0], c)
def test_faces_and_tokens():
assert [token(x) for x in FACE] == ["1", "3", "5", "7", "9", "b"]
for x in FACE:
assert level(x) == 0 and is_valid(x)
assert from_token(token(x)) == x
assert not is_valid(0) and not is_valid(2)
with pytest.raises(ValueError):
children(from_latlng(1, 2))
with pytest.raises(ValueError):
parent(FACE[0], 1)
def corners(c):
f, i0, j0, s = ij_bounds(c)
out = set()
for di in (0, s):
for dj in (0, s):
p = face_uv_to_xyz(f, st_to_uv((i0 + di) / MAX_SIZE), st_to_uv((j0 + dj) / MAX_SIZE))
n = sum(a * a for a in p) ** 0.5
out.add(tuple(round(a / n, 9) for a in p))
return out
def test_hilbert_order_is_continuous():
lev = 3
cells = []
for f in range(6):
c = parent(range_min(FACE[f]), lev)
for _ in range(4 ** lev):
cells.append(c)
c += 2 * lsb(c)
for a, b in zip(cells, cells[1:] + cells[:1]):
assert len(corners(a) & corners(b)) == 2 # consecutive cells share an edge
def test_random_against_brute_force():
rng = random.Random(5)
for trial in range(600):
lat, lng = rng.uniform(-90, 90), rng.uniform(-180, 180)
leaf = from_latlng(lat, lng)
f, u, v = xyz_to_face_uv(latlng_to_xyz(lat, lng))
i, j = st_to_ij(uv_to_st(u)), st_to_ij(uv_to_st(v))
assert to_face_ij(leaf)[:3] == (f, i, j)
assert from_face_ij(f, i, j) == leaf
lev = rng.randint(0, MAX_LEVEL)
c = parent(leaf, lev)
assert level(c) == lev and contains(c, leaf) and is_valid(c)
# descend from the face by children, choosing by (i, j): same cell
d = FACE[f]
for _ in range(lev):
for k in children(d):
_, i0, j0, s = ij_bounds(k)
if i0 <= i < i0 + s and j0 <= j < j0 + s:
d = k
break
assert d == c
if lev < MAX_LEVEL:
ks = children(c)
assert all(parent(k, lev) == c for k in ks)
assert range_min(ks[0]) == range_min(c) and range_max(ks[3]) == range_max(c)
assert sum(contains(k, leaf) for k in ks) == 1
assert from_token(token(c)) == c
What the next level needs
- the tests did not run in this build