Region quadtree in Python
Quadtree that recursively subdivides a binary image or region into homogeneous quadrants.
- File
region_quadtree.py, 182 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/region-quadtree/. The authors are listed in prov/74TKQQ.jsonl.
"""Region quadtree of a 2^D by 2^D binary image.
A node is either a leaf, stored as the int 0 (white) or 1 (black), or an
internal node, stored as a list of four children in the order NW, NE, SW, SE.
The child index of pixel (x, y) in a block of side s with origin (x0, y0) is
2 * (y >= y0 + s/2) + (x >= x0 + s/2). Row y = 0 is the top row.
The tree is kept *maximal*: no internal node has four leaf children of one
color.
"""
from __future__ import annotations
from typing import List, Union
Node = Union[int, list]
def _merge(children: list) -> Node:
"""Return a leaf if the four children are equal leaves, else the list."""
a = children[0]
if isinstance(a, int) and all(c == a for c in children): # @line L4
return a
return children # @line L5
def build(image: List[List[int]]) -> Node:
"""Build the region quadtree bottom up, level by level, in O(N^2) time."""
n = len(image)
if n == 0 or n & (n - 1) or any(len(row) != n for row in image):
raise ValueError("image must be square with a power-of-two side")
level: List[List[Node]] = [[1 if image[y][x] else 0 for x in range(n)]
for y in range(n)] # @line L1
s = n
while s > 1: # @line L2
h = s // 2
nxt: List[List[Node]] = []
for y in range(h):
row: List[Node] = []
for x in range(h):
kids = [level[2 * y][2 * x], level[2 * y][2 * x + 1],
level[2 * y + 1][2 * x], level[2 * y + 1][2 * x + 1]] # @line L3
row.append(_merge(kids))
nxt.append(row)
level = nxt
s = h
return level[0][0]
class RegionQuadTree:
def __init__(self, image: List[List[int]]) -> None:
self.n = len(image)
self.root: Node = build(image)
def get(self, x: int, y: int) -> int:
"""Color of pixel (x, y)."""
if not (0 <= x < self.n and 0 <= y < self.n):
raise IndexError("pixel outside the image")
node, s, x0, y0 = self.root, self.n, 0, 0
while not isinstance(node, int): # @line L6
s //= 2
q = 2 * (y >= y0 + s) + (x >= x0 + s) # @line L7
x0 += s if q & 1 else 0
y0 += s if q & 2 else 0
node = node[q]
return node # @line L8
def set(self, x: int, y: int, color: int) -> None:
"""Set pixel (x, y) to color, splitting and merging as needed."""
if not (0 <= x < self.n and 0 <= y < self.n):
raise IndexError("pixel outside the image")
color = 1 if color else 0
if self.get(x, y) == color: # @line L9
return
if isinstance(self.root, int):
self.root = [self.root] * 4 if self.n > 1 else color
if self.n == 1:
return
path: List[tuple] = [] # (parent list, index)
node, s, x0, y0 = self.root, self.n, 0, 0
while s > 1: # @line L10
s //= 2
q = 2 * (y >= y0 + s) + (x >= x0 + s)
x0 += s if q & 1 else 0
y0 += s if q & 2 else 0
child = node[q]
if isinstance(child, int) and s > 1:
child = [child] * 4 # @line L11
node[q] = child
path.append((node, q))
node = child
parent, q = path[-1]
parent[q] = color # @line L12
for i in range(len(path) - 1, 0, -1): # @line L13
p, pq = path[i - 1]
p[pq] = _merge(path[i][0])
self.root = _merge(self.root)
def black_area(self) -> int:
"""Number of black pixels, from the leaves."""
total = 0
stack = [(self.root, self.n)]
while stack:
node, s = stack.pop()
if isinstance(node, int):
total += node * s * s
else:
for c in node:
stack.append((c, s // 2))
return total
def leaf_count(self) -> int:
cnt, stack = 0, [self.root]
while stack:
node = stack.pop()
if isinstance(node, int):
cnt += 1
else:
stack.extend(node)
return cnt
def node_count(self) -> int:
cnt, stack = 0, [self.root]
while stack:
node = stack.pop()
cnt += 1
if not isinstance(node, int):
stack.extend(node)
return cnt
def to_image(self) -> List[List[int]]:
img = [[0] * self.n for _ in range(self.n)]
stack = [(self.root, 0, 0, self.n)]
while stack:
node, x0, y0, s = stack.pop()
if isinstance(node, int):
if node:
for y in range(y0, y0 + s):
for x in range(x0, x0 + s):
img[y][x] = 1
else:
h = s // 2
for q in range(4):
stack.append((node[q], x0 + h * (q & 1), y0 + h * (q >> 1), h))
return img
def check_invariant(self) -> None:
"""Every internal node has four children and is not mergeable."""
stack = [(self.root, self.n)]
while stack:
node, s = stack.pop()
if isinstance(node, int):
assert node in (0, 1)
continue
assert s > 1 and len(node) == 4
assert not (isinstance(node[0], int)
and all(c == node[0] for c in node)), "mergeable node"
for c in node:
stack.append((c, s // 2))
def union(a: Node, b: Node) -> Node:
"""Union of two region quadtrees of the same image size."""
if a == 1 or b == 1: # @line L14
return 1
if a == 0: # @line L15
return b
if b == 0:
return a
return _merge([union(a[i], b[i]) for i in range(4)]) # @line L16
def intersection(a: Node, b: Node) -> Node:
"""Intersection of two region quadtrees of the same image size."""
if a == 0 or b == 0:
return 0
if a == 1:
return b
if b == 1:
return a
return _merge([intersection(a[i], b[i]) for i in range(4)]) # @line L17
test_region_quadtree.py 72 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/region-quadtree/. The authors are listed in prov/74TKQQ.jsonl.
import random
import pytest
from region_quadtree import RegionQuadTree, build, intersection, union
ROWS = ["00000000", "00000000", "00001111", "00001111",
"00011111", "00111111", "00111100", "00111100"]
IMG = [[int(c) for c in r] for r in ROWS]
def test_page_example():
t = RegionQuadTree(IMG)
assert t.root == [0, [0, 0, 1, 1], [0, [0, 1, 1, 1], 0, 1], [1, 1, 1, 0]]
assert (t.leaf_count(), t.node_count(), t.black_area()) == (16, 21, 27)
assert t.get(5, 3) == 1 and t.get(1, 1) == 0
t.set(2, 4, 1)
assert t.root == [0, [0, 0, 1, 1], [0, 1, 0, 1], [1, 1, 1, 0]]
assert (t.leaf_count(), t.node_count(), t.black_area()) == (13, 17, 28)
t.set(2, 4, 0)
assert t.root == build(IMG)
t.set(0, 0, 1)
assert (t.leaf_count(), t.node_count()) == (22, 29)
t.set(0, 0, 0)
assert t.root == build(IMG)
def test_edge_cases():
assert build([[0]]) == 0 and build([[1]]) == 1
t = RegionQuadTree([[0]])
t.set(0, 0, 1)
assert t.root == 1
assert build([[1, 1], [1, 1]]) == 1
assert build([[1, 0], [0, 0]]) == [1, 0, 0, 0]
t = RegionQuadTree([[0] * 4 for _ in range(4)])
t.set(3, 3, 1)
assert t.root == [0, 0, 0, [0, 0, 0, 1]]
with pytest.raises(ValueError):
build([[0, 0, 0]] * 3)
with pytest.raises(IndexError):
t.get(4, 0)
def rand_img(rng, n, p):
return [[1 if rng.random() < p else 0 for _ in range(n)] for _ in range(n)]
def test_random_against_array():
rng = random.Random(20261104)
for _ in range(600):
n = 1 << rng.randint(0, 4)
img = rand_img(rng, n, rng.choice([0.05, 0.5, 0.95]))
t = RegionQuadTree(img)
t.check_invariant()
assert t.to_image() == img
for _ in range(8):
x, y, c = rng.randrange(n), rng.randrange(n), rng.randint(0, 1)
t.set(x, y, c)
img[y][x] = c
t.check_invariant()
assert t.get(x, y) == c
assert t.to_image() == img
assert t.root == build(img) # maximal trees are unique
assert t.black_area() == sum(map(sum, img))
other = rand_img(rng, n, 0.3)
b = build(other)
u = union(t.root, b)
i = intersection(t.root, b)
assert u == build([[a | c for a, c in zip(r, s)] for r, s in zip(img, other)])
assert i == build([[a & c for a, c in zip(r, s)] for r, s in zip(img, other)])
What the next level needs
- the tests did not run in this build