Algorithm Search⌘K

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
str_packing.pyRawGitHub
# 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

Read the full page about STR packing