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
# 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