Algorithm Search⌘K

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

Read the full page about Region quadtree