STR packing in Python
Bulk-loads an R-tree by sorting on one axis, tiling into vertical slices, and recursing on the other axes.
- File
str_packing.py, 113 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/str-packing/. The authors are listed in prov/4SAGYZ.jsonl.
"""Sort-Tile-Recursive (STR) bulk loading of an R-tree in the plane.
Rectangles are (xmin, ymin, xmax, ymax), closed. Leaf entries are
(rect, id); internal entries are (rect, Node). Every level is built by the
same tiling step, applied to the entries of the level below.
"""
from __future__ import annotations
import math
from typing import Hashable, List, Tuple
Rect = Tuple[float, float, float, float]
def union(a: Rect, b: Rect) -> Rect:
return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]))
def intersects(a: Rect, b: Rect) -> bool:
return a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
class Node:
__slots__ = ("leaf", "entries", "rect")
def __init__(self, leaf: bool, entries: list) -> None:
self.leaf = leaf
self.entries = entries
r = entries[0][0]
for e in entries[1:]:
r = union(r, e[0])
self.rect: Rect = r
def _cx(e) -> float:
return (e[0][0] + e[0][2]) / 2
def _cy(e) -> float:
return (e[0][1] + e[0][3]) / 2
def tile(entries: list, M: int, leaf: bool) -> List[Node]:
"""One STR pass: group the entries into ceil(len / M) nodes."""
P = math.ceil(len(entries) / M) # @line L2
S = math.ceil(math.sqrt(P))
by_x = sorted(entries, key=_cx) # @line L3
nodes: List[Node] = []
for s in range(0, len(by_x), S * M): # @line L4
slab = sorted(by_x[s:s + S * M], key=_cy) # @line L5
for t in range(0, len(slab), M):
nodes.append(Node(leaf, slab[t:t + M]))
return nodes
class STRTree:
def __init__(self, items: List[Tuple[Hashable, Rect]], M: int = 4) -> None:
if M < 2:
raise ValueError("M must be at least 2")
self.M = M
self.n = len(items)
self.levels: List[List[Node]] = []
if not items:
self.root = None
return
entries: list = [(r, oid) for oid, r in items]
leaf = True
while True: # @line L1
nodes = tile(entries, M, leaf)
self.levels.append(nodes)
if len(nodes) == 1:
break
entries = [(nd.rect, nd) for nd in nodes] # @line L6
leaf = False
self.root = self.levels[-1][0]
def height(self) -> int:
return len(self.levels) - 1
def search(self, q: Rect) -> List[Hashable]:
out: List[Hashable] = []
if self.root is None or not intersects(self.root.rect, q):
return out
stack = [self.root]
while stack:
nd = stack.pop()
for r, x in nd.entries: # @line L7
if intersects(r, q):
if nd.leaf:
out.append(x) # @line L8
else:
stack.append(x) # @line L9
return out
def check_invariant(self) -> None:
seen = []
for lvl, nodes in enumerate(self.levels):
for nd in nodes:
assert 1 <= len(nd.entries) <= self.M
assert nd.leaf == (lvl == 0)
r = nd.entries[0][0]
for e in nd.entries[1:]:
r = union(r, e[0])
assert r == nd.rect
if not nd.leaf:
for rr, c in nd.entries:
assert rr == c.rect
else:
seen.extend(x for _, x in nd.entries)
assert len(seen) == self.n
test_str_packing.py 64 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/str-packing/. The authors are listed in prov/4SAGYZ.jsonl.
import math
import random
import pytest
from str_packing import STRTree, intersects
PTS = [(1, 1), (2, 7), (3, 4), (4, 9), (5, 2), (6, 6), (7, 1), (8, 8),
(9, 3), (10, 10), (11, 5), (12, 2), (13, 7), (14, 4), (15, 9), (16, 1)]
NAMES = "ABCDEFGHIJKLMNOP"
def test_page_example():
t = STRTree([(NAMES[i], (x, y, x, y)) for i, (x, y) in enumerate(PTS)], 4)
t.check_invariant()
leaves = [("".join(e[1] for e in nd.entries), nd.rect) for nd in t.levels[0]]
assert leaves == [("AGEC", (1, 1, 7, 4)), ("FBHD", (2, 6, 8, 9)),
("PLIN", (9, 1, 16, 4)), ("KMOJ", (10, 5, 15, 10))]
assert t.height() == 1 and t.root.rect == (1, 1, 16, 10)
assert sorted(t.search((5, 5, 10, 9))) == ["F", "H"]
assert t.search((0, 0, 0.5, 0.5)) == []
def test_edge_cases():
t = STRTree([], 4)
assert t.root is None and t.search((0, 0, 1, 1)) == []
t = STRTree([("a", (0, 0, 1, 1))], 4)
assert t.height() == 0 and t.search((1, 1, 2, 2)) == ["a"]
t = STRTree([(i, (0, 0, 0, 0)) for i in range(10)], 3) # all equal
t.check_invariant()
assert sorted(t.search((0, 0, 0, 0))) == list(range(10))
with pytest.raises(ValueError):
STRTree([], 1)
def test_random_against_brute_force():
rng = random.Random(97)
for _ in range(500):
n = rng.randint(1, 120)
M = rng.randint(2, 9)
items = []
for k in range(n):
x, y = rng.uniform(0, 100), rng.uniform(0, 100)
items.append((k, (x, y, x + rng.uniform(0, 8), y + rng.uniform(0, 8))))
t = STRTree(items, M)
t.check_invariant()
# level sizes are ceil(previous / M), so the height is ceil(log_M n)
size = n
for nodes in t.levels:
size = math.ceil(size / M)
assert len(nodes) == size
assert t.height() == (0 if n <= M else len(t.levels) - 1)
h, cap = 0, M
while cap < n:
cap *= M
h += 1
assert t.height() == h
for _ in range(5):
x, y = rng.uniform(-5, 100), rng.uniform(-5, 100)
q = (x, y, x + rng.uniform(0, 30), y + rng.uniform(0, 30))
got = t.search(q)
assert sorted(got) == sorted(k for k, r in items if intersects(q, r))
What the next level needs
- the tests did not run in this build