Algorithm Search⌘K

Linear quadtree in Python

Pointerless quadtree stored as a sorted list of locational codes, usually Morton codes.

File
linear_quadtree.py, 188 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
linear_quadtree.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/linear-quadtree/. The authors are listed in prov/HXZGKW.jsonl.

from bisect import bisect_left, bisect_right
from typing import Iterable

Pixel = tuple[int, int]
Block = tuple[int, int]  # (Morton code of the lower left pixel, level)


def morton(x: int, y: int, depth: int) -> int:
    """Interleave the bits of x and y: bit i of x goes to bit 2i, bit i of y to bit 2i+1."""
    z = 0
    for i in range(depth):
        z |= ((x >> i) & 1) << (2 * i)
        z |= ((y >> i) & 1) << (2 * i + 1)
    return z


def demorton(z: int, depth: int) -> Pixel:
    """The inverse of morton."""
    x = y = 0
    for i in range(depth):
        x |= ((z >> (2 * i)) & 1) << i
        y |= ((z >> (2 * i + 1)) & 1) << i
    return x, y


class LinearQuadtree:
    """The black pixels of a 2^depth by 2^depth binary image, as a sorted list of blocks.

    A block (c, l) is the aligned square of side 2^l whose pixels have the Morton
    codes c to c + 4^l - 1. Invariant: c is a multiple of 4^l, the blocks are
    disjoint and sorted by code, and no four sibling blocks of one level are all
    present (they would form their parent). So the list is the set of black
    leaves of the region quadtree of the image, and it is unique.
    """

    def __init__(self, depth: int) -> None:
        if depth < 0:
            raise ValueError("need depth >= 0")
        self.depth = depth
        self.codes: list[int] = []
        self.levels: list[int] = []

    @classmethod
    def from_pixels(cls, depth: int, pixels: Iterable[Pixel]) -> "LinearQuadtree":
        """Build from a set of black pixels with one sort and one stack pass. O(n log n)."""
        t = cls(depth)
        zs = sorted({morton(*t._norm(p), depth) for p in pixels})
        stack: list[Block] = []
        for z in zs:  # @line L1
            stack.append((z, 0))  # @line L2
            while len(stack) >= 4:  # @line L3
                c, l = stack[-4]
                step = 1 << (2 * l)
                if c % (step << 2) != 0 or any(stack[-4 + k] != (c + k * step, l) for k in range(1, 4)):  # @line L4
                    break
                del stack[-4:]  # @line L5
                stack.append((c, l + 1))  # @line L6
        t.codes = [c for c, _ in stack]
        t.levels = [l for _, l in stack]
        return t

    def __len__(self) -> int:
        return len(self.codes)

    def _norm(self, p: Pixel) -> Pixel:
        x, y = int(p[0]), int(p[1])
        if not (0 <= x < (1 << self.depth) and 0 <= y < (1 << self.depth)):
            raise ValueError("the pixel is outside the image")
        return x, y

    def _locate(self, z: int) -> int:
        """Index of the block that holds the code z, or -1."""
        i = bisect_right(self.codes, z) - 1  # @line L7
        if i >= 0 and z < self.codes[i] + (1 << (2 * self.levels[i])):  # @line L8
            return i
        return -1  # @line L9

    def locate(self, p: Pixel) -> tuple[int, int, int] | None:
        """The block (x, y, side) that holds the pixel p, or None if p is white. O(log n)."""
        i = self._locate(morton(*self._norm(p), self.depth))
        if i < 0:
            return None
        x, y = demorton(self.codes[i], self.depth)
        return x, y, 1 << self.levels[i]

    def __contains__(self, p: Pixel) -> bool:
        return self._locate(morton(*self._norm(p), self.depth)) >= 0

    def _has(self, c: int, l: int) -> bool:
        i = bisect_left(self.codes, c)
        return i < len(self.codes) and self.codes[i] == c and self.levels[i] == l

    def _add(self, c: int, l: int) -> None:
        i = bisect_left(self.codes, c)
        self.codes.insert(i, c)
        self.levels.insert(i, l)

    def _remove(self, c: int) -> None:
        i = bisect_left(self.codes, c)
        del self.codes[i]
        del self.levels[i]

    def insert(self, p: Pixel) -> bool:
        """Make the pixel p black. Returns False if it was black. O(depth) list updates."""
        z = morton(*self._norm(p), self.depth)
        if self._locate(z) >= 0:  # @line L10
            return False
        c, l = z, 0
        self._add(c, l)  # @line L11
        while l < self.depth:
            step = 1 << (2 * l)
            parent = c - c % (step << 2)
            if not all(self._has(parent + k * step, l) for k in range(4)):  # @line L12
                break
            for k in range(4):  # @line L13
                self._remove(parent + k * step)
            c, l = parent, l + 1
            self._add(c, l)
        return True

    def delete(self, p: Pixel) -> bool:
        """Make the pixel p white. Returns False if it was white. O(depth) list updates."""
        z = morton(*self._norm(p), self.depth)
        i = self._locate(z)  # @line L14
        if i < 0:
            return False
        l = self.levels[i]
        self._remove(self.codes[i])  # @line L15
        for j in range(l - 1, -1, -1):  # @line L16
            step = 1 << (2 * j)
            base = z - z % (step << 2)
            for k in range(4):  # @line L17
                if base + k * step != z - z % step:
                    self._add(base + k * step, j)
        return True

    def blocks_in_cell(self, x: int, y: int, level: int) -> list[tuple[int, int, int]]:
        """The black blocks inside the aligned cell of side 2^level at (x, y), clipped to it.

        The blocks inside a cell form one run of the sorted list, found by two
        binary searches. A block that covers the whole cell is returned as the cell.
        """
        if level < 0 or level > self.depth or x % (1 << level) or y % (1 << level):
            raise ValueError("need an aligned cell inside the image")
        lo = morton(*self._norm((x, y)), self.depth)
        hi = lo + (1 << (2 * level))
        i = self._locate(lo)
        if i >= 0 and self.levels[i] >= level:
            return [(x, y, 1 << level)]
        out = []
        for j in range(bisect_left(self.codes, lo), bisect_left(self.codes, hi)):
            bx, by = demorton(self.codes[j], self.depth)
            out.append((bx, by, 1 << self.levels[j]))
        return out

    def blocks(self) -> list[tuple[int, int, int]]:
        """All blocks as (x, y, side), in Morton order."""
        return [(*demorton(c, self.depth), 1 << l) for c, l in zip(self.codes, self.levels)]

    def area(self) -> int:
        """The number of black pixels."""
        return sum(1 << (2 * l) for l in self.levels)

    def pixels(self) -> list[Pixel]:
        out = []
        for c, l in zip(self.codes, self.levels):
            for z in range(c, c + (1 << (2 * l))):
                out.append(demorton(z, self.depth))
        return out

    def check_invariant(self) -> None:
        full = 1 << (2 * self.depth)
        assert len(self.codes) == len(self.levels)
        end = 0
        for c, l in zip(self.codes, self.levels):
            assert 0 <= l <= self.depth, "level out of range"
            assert c % (1 << (2 * l)) == 0, "block not aligned"
            assert c >= end, "blocks overlap or are out of order"
            end = c + (1 << (2 * l))
            assert end <= full, "block outside the image"
        for c, l in zip(self.codes, self.levels):
            if l < self.depth:
                step = 1 << (2 * l)
                parent = c - c % (step << 2)
                assert not all(self._has(parent + k * step, l) for k in range(4)), "four siblings not merged"
test_linear_quadtree.py 104 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/linear-quadtree/. The authors are listed in prov/HXZGKW.jsonl.

import random

import pytest

from linear_quadtree import LinearQuadtree, demorton, morton


def oracle(depth: int, black: set[tuple[int, int]]) -> list[tuple[int, int, int]]:
    """The black leaves of the region quadtree, in Morton order, by recursion on cells."""
    out = []
    stack = [(0, 0, 1 << depth)]
    while stack:
        x, y, s = stack.pop()
        cells = [(i, j) for i in range(x, x + s) for j in range(y, y + s)]
        n = sum(1 for c in cells if c in black)
        if n == len(cells):
            out.append((x, y, s))
        elif n > 0:
            h = s // 2
            # push in reverse quadrant order so SW comes out first
            for bx, by in [(1, 1), (0, 1), (1, 0), (0, 0)]:
                stack.append((x + bx * h, y + by * h, h))
    return out


def test_morton() -> None:
    assert morton(2, 0, 2) == 4 and morton(1, 3, 2) == 11 and morton(3, 1, 2) == 7
    for z in range(256):
        assert morton(*demorton(z, 4), 4) == z


def test_page_example() -> None:
    black = [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0), (3, 0), (2, 1), (1, 3)]
    t = LinearQuadtree.from_pixels(2, black)
    t.check_invariant()
    assert list(zip(t.codes, t.levels)) == [(0, 1), (4, 0), (5, 0), (6, 0), (11, 0)]
    assert t.area() == 8 and len(t) == 5
    assert t.locate((1, 1)) == (0, 0, 2)
    assert t.locate((3, 1)) is None
    assert t.blocks_in_cell(2, 0, 1) == [(2, 0, 1), (3, 0, 1), (2, 1, 1)]
    assert t.insert((3, 1))
    t.check_invariant()
    assert list(zip(t.codes, t.levels)) == [(0, 1), (4, 1), (11, 0)]
    assert t.delete((0, 0))
    t.check_invariant()
    assert list(zip(t.codes, t.levels)) == [(1, 0), (2, 0), (3, 0), (4, 1), (11, 0)]


def test_edges() -> None:
    t = LinearQuadtree(0)
    assert (0, 0) not in t and t.locate((0, 0)) is None
    assert t.insert((0, 0)) and not t.insert((0, 0))
    assert t.blocks() == [(0, 0, 1)]
    assert t.delete((0, 0)) and not t.delete((0, 0)) and len(t) == 0
    with pytest.raises(ValueError):
        t.insert((1, 0))
    with pytest.raises(ValueError):
        LinearQuadtree(-1)
    full = LinearQuadtree.from_pixels(3, [(i, j) for i in range(8) for j in range(8)])
    assert full.blocks() == [(0, 0, 8)]
    assert full.blocks_in_cell(4, 4, 1) == [(4, 4, 2)]
    assert full.delete((7, 7))
    full.check_invariant()
    assert len(full) == 9 and full.area() == 63
    with pytest.raises(ValueError):
        full.blocks_in_cell(1, 0, 1)


def test_random_against_oracle() -> None:
    rng = random.Random(20261104)
    for case in range(600):
        depth = rng.randint(0, 4)
        side = 1 << depth
        density = rng.random()
        black = {(i, j) for i in range(side) for j in range(side) if rng.random() < density}
        t = LinearQuadtree.from_pixels(depth, black)
        t.check_invariant()
        assert t.blocks() == oracle(depth, black)
        u = LinearQuadtree(depth)
        for p in rng.sample(sorted(black), len(black)):
            assert u.insert(p)
        assert u.codes == t.codes and u.levels == t.levels
        for _ in range(20):
            p = (rng.randrange(side), rng.randrange(side))
            assert (p in t) == (p in black)
            if rng.random() < 0.5:
                assert t.insert(p) == (p not in black)
                black.add(p)
            else:
                assert t.delete(p) == (p in black)
                black.discard(p)
            t.check_invariant()
        assert t.blocks() == oracle(depth, black)
        assert sorted(t.pixels()) == sorted(black)
        lvl = rng.randint(0, depth)
        cx = rng.randrange(side >> lvl) << lvl
        cy = rng.randrange(side >> lvl) << lvl
        got = t.blocks_in_cell(cx, cy, lvl)
        cell = {(i, j) for i in range(cx, cx + (1 << lvl)) for j in range(cy, cy + (1 << lvl))}
        covered = {(i, j) for x, y, s in got for i in range(x, x + s) for j in range(y, y + s)}
        assert covered == cell & black

What the next level needs

  • the tests did not run in this build

Read the full page about Linear quadtree