【Python】四分木の中で最も複雑な領域を分割し続けるアートを実装してみた

# はじめに
数ヶ月前に、[このツイート](https://x.com/BaroqueEngine/status/1549009803925950464?s=20)が目に留まりました。

https://x.com/BaroqueEngine/status/1549009803925950464?s=20

非常に魅力的で、自分でも作りたいと思ったのですが、アルゴリズムや実装が公開されているにもかかわらず、実際にやっている人が少ないようでした。

そこで、本記事では、Pythonの画像処理ライブラリPillow(PIL)を使用して、四分木の中で最も複雑な領域を分割し続けるアートの実装方法について解説します。

# アルゴリズム

以下の操作を再帰的に繰り返します。
- キャンバス上のすべての矩形領域の中から、最も複雑な領域を選んで四分割する。
- 新しくできた矩形領域において画像の複雑度(score)と平均色を求め、領域を平均色で塗りつぶす。

詳しくは元記事を参照してください。

https://zenn.dev/baroqueengine/articles/ccf3ac7a2c9332

# 実装

## Rectクラス

`Rect`クラスは、長方形のフレームの座標情報を保持するクラスです。

`calc_area`は長方形のフレームの面積を計算するメソッドです。

```python
class Rect:
    def __init__(self, left: int, right: int, top: int, bottom: int):
        self.left = left
        self.right = right
        self.top = top
        self.bottom = bottom

    def calc_area(self) -> int:
        return (self.right - self.left + 1) * (self.bottom - self.top + 1)
```

## Quadクラス

`Quad`クラスは、四分木(Quadtree)のノードを表すクラスであり、フレームの座標情報(rect)と複雑度(score)、平均色(color)を持たせています。

heapqで使用するため、`__lt__`により比較演算子を定義しています。

```python
class Quad:
    def __init__(self, rect: Rect, score: float, color: tuple):
        self.rect = rect
        self.score = score
        self.color = color

    def __lt__(self, other):
        return self.score < other.score
```

## QuadTreeGeneratorクラス

`QuadTreeGenerator`クラスは、メインの処理を実装したクラスです。画像の読み込み、分割処理、結果の描画などが含まれています。

```python
class QuadTreeGenerator:
    def __init__(
        self,
        input_path: str,
        iterations: int,
        min_size: int = 4,
        area_power: float = 0.2,
    ):
        self.img = Image.open(fp=input_path)
        self.img_arr = np.asarray(self.img)
        self.iterations = iterations  # 分割を繰り返す回数
        self.min_size = min_size  # 分割を終了する最小のサイズ
        self.area_power = area_power  # 面積のスコアに対する重み

        self.gif_frames = []  # GIFのフレーム
        self.canvas = Image.new("RGB", self.img.size)  # Canvas
        self.draw = ImageDraw.Draw(self.canvas)  # Canvasに描画するためのオブジェクト

        root = self.create_quad(
            rect=Rect(0, self.img.width - 1, 0, self.img.height - 1)
        )
        self.heap = []  # すべてのquadを管理する優先度付きキュー
        heapq.heappush(self.heap, root)

    # 再帰的にを四分木の中で最も複雑な領域を分割し続けるメソッド。指定された回数分だけ`Quad`を生成し、優先度付きキューに追加します。
    def generate_quad_tree(self):
        for _ in range(self.iterations):
            quad = heapq.heappop(self.heap)
            quads = self.split(quad.rect)
            for q in quads:
                heapq.heappush(self.heap, q)
            self.render_and_append_frame(quads=quads)

    # 新しく生成された`Quad`をキャンバスに描画して、`self.gif_frames`にフレームとして追加するメソッド。
    def render_and_append_frame(self, quads: list):
        for quad in quads:
            self.draw.rectangle(
                [
                    (quad.rect.left, quad.rect.top),
                    (quad.rect.right, quad.rect.bottom),
                ],
                fill=quad.color,
                outline=(0, 0, 0),  # 枠線の色は黒
            )
        self.gif_frames.append(self.canvas.copy())

    # 指定された`Rect`から`Quad`オブジェクトを生成するメソッド。最小サイズ以下であれば、分割を終了します。
    def create_quad(self, rect: Rect) -> Quad:
        r_color, r_error = self.calc_color_and_error(rect=rect, color_index=0)
        g_color, g_error = self.calc_color_and_error(rect=rect, color_index=1)
        b_color, b_error = self.calc_color_and_error(rect=rect, color_index=2)
        error = (r_error + g_error + b_error) / 3

        score = -(error * rect.calc_area() ** self.area_power)

        if self.is_quad_below_min_size(rect=rect):
            score = 0

        return Quad(rect=rect, score=score, color=(r_color, g_color, b_color))

    # 指定された`Rect`と色のチャンネルに対して、平均色と複雑度を計算して返すメソッド。
    def calc_color_and_error(self, rect: Rect, color_index: int) -> tuple:
        hist = self.get_rect_color(rect=rect, color_index=color_index)
        total = np.sum(hist * np.arange(len(hist)))
        pixel_num = np.sum(hist)
        average_color = total / pixel_num

        error = (
            np.sum(hist * (np.arange(len(hist)) - average_color) ** 2) / pixel_num
        ) ** 0.5

        return int(average_color), error

    # 指定された`Rect`と色のチャンネルに対して、元の画像の色の分布を`numpy.histogram`として返すメソッド。平均色や複雑度の計算に利用されます。
    def get_rect_color(self, rect: Rect, color_index: int) -> np.ndarray:
        hist, _ = np.histogram(
            self.img_arr[
                rect.top : rect.bottom + 1, rect.left : rect.right + 1, color_index
            ],
            bins=256,
            range=(0, 256),
        )
        return hist

    # 指定された`Rect`が最小サイズ以下かどうかを判定するメソッド。
    def is_quad_below_min_size(self, rect: Rect) -> bool:
        return (
            rect.right - rect.left + 1 <= self.min_size
            or rect.bottom - rect.top + 1 <= self.min_size
        )

    # 指定された`Rect`を四分割して生成された、新しい`Quad`のリストを返すメソッド。
    def split(self, rect: Rect) -> list:
        x_center = (rect.left + rect.right) // 2
        y_center = (rect.top + rect.bottom) // 2

        rects = [
            Rect(rect.left, x_center - 1, rect.top, y_center - 1),
            Rect(x_center, rect.right, rect.top, y_center - 1),
            Rect(rect.left, x_center - 1, y_center, rect.bottom),
            Rect(x_center, rect.right, y_center, rect.bottom),
        ]
        quads = [self.create_quad(rect) for rect in rects]
        return quads

    # `self.gif_frames`のフレームを繋いで、アニメーションGIFを生成するメソッド。
    def save_gif(self, output_path: str, duration: int = 10):
        self.gif_frames[0].save(
            output_path,
            save_all=True,
            append_images=self.gif_frames[1:],
            duration=duration,
            loop=0,
        )
```

:::note warn
`heapq.heappop`は最小の要素を取り出すことに注意してください。今回は複雑度が最も高い`Quad`を取り出すために、スコアをあらかじめ -1 倍しています。
:::

## コード全体

```text:requirements.txt
numpy==1.26.2
pillow==10.1.0
```

```python:main.py
from PIL import Image, ImageDraw
import numpy as np
import heapq


class Rect:
    def __init__(self, left: int, right: int, top: int, bottom: int):
        self.left = left
        self.right = right
        self.top = top
        self.bottom = bottom

    def calc_area(self) -> int:
        return (self.right - self.left + 1) * (self.bottom - self.top + 1)


class Quad:
    def __init__(self, rect: Rect, score: float, color: tuple):
        self.rect = rect
        self.score = score
        self.color = color

    def __lt__(self, other):
        return self.score < other.score


class QuadTreeGenerator:
    def __init__(
        self,
        input_path: str,
        iterations: int,
        min_size: int = 4,
        area_power: float = 0.2,
    ):
        self.img = Image.open(fp=input_path)
        self.img_arr = np.asarray(self.img)
        self.iterations = iterations  # 分割を繰り返す回数
        self.min_size = min_size  # 分割を終了する最小のサイズ
        self.area_power = area_power  # 面積のスコアに対する重み

        self.gif_frames = []  # GIFのフレーム
        self.canvas = Image.new("RGB", self.img.size)  # Canvas
        self.draw = ImageDraw.Draw(self.canvas)  # Canvasに描画するためのオブジェクト

        root = self.create_quad(
            rect=Rect(0, self.img.width - 1, 0, self.img.height - 1)
        )
        self.heap = []  # すべてのquadを管理する優先度付きキュー
        heapq.heappush(self.heap, root)

    # 再帰的にを四分木の中で最も複雑な領域を分割し続けるメソッド。指定された回数分だけ`Quad`を生成し、優先度付きキューに追加します。
    def generate_quad_tree(self):
        for _ in range(self.iterations):
            quad = heapq.heappop(self.heap)
            quads = self.split(quad.rect)
            for q in quads:
                heapq.heappush(self.heap, q)
            self.render_and_append_frame(quads=quads)

    # 新しく生成された`Quad`をキャンバスに描画して、`self.gif_frames`にフレームとして追加するメソッド。
    def render_and_append_frame(self, quads: list):
        for quad in quads:
            self.draw.rectangle(
                [
                    (quad.rect.left, quad.rect.top),
                    (quad.rect.right, quad.rect.bottom),
                ],
                fill=quad.color,
                outline=(0, 0, 0),  # 枠線の色は黒
            )
        self.gif_frames.append(self.canvas.copy())

    # 指定された`Rect`から`Quad`オブジェクトを生成するメソッド。最小サイズ以下であれば、分割を終了します。
    def create_quad(self, rect: Rect) -> Quad:
        r_color, r_error = self.calc_color_and_error(rect=rect, color_index=0)
        g_color, g_error = self.calc_color_and_error(rect=rect, color_index=1)
        b_color, b_error = self.calc_color_and_error(rect=rect, color_index=2)
        error = (r_error + g_error + b_error) / 3

        score = -(error * rect.calc_area() ** self.area_power)

        if self.is_quad_below_min_size(rect=rect):
            score = 0

        return Quad(rect=rect, score=score, color=(r_color, g_color, b_color))

    # 指定された`Rect`と色のチャンネルに対して、平均色と複雑度を計算して返すメソッド。
    def calc_color_and_error(self, rect: Rect, color_index: int) -> tuple:
        hist = self.get_rect_color(rect=rect, color_index=color_index)
        total = np.sum(hist * np.arange(len(hist)))
        pixel_num = np.sum(hist)
        average_color = total / pixel_num

        error = (
            np.sum(hist * (np.arange(len(hist)) - average_color) ** 2) / pixel_num
        ) ** 0.5

        return int(average_color), error

    # 指定された`Rect`と色のチャンネルに対して、元の画像の色の分布を`numpy.histogram`として返すメソッド。平均色や複雑度の計算に利用されます。
    def get_rect_color(self, rect: Rect, color_index: int) -> np.ndarray:
        hist, _ = np.histogram(
            self.img_arr[
                rect.top : rect.bottom + 1, rect.left : rect.right + 1, color_index
            ],
            bins=256,
            range=(0, 256),
        )
        return hist

    # 指定された`Rect`が最小サイズ以下かどうかを判定するメソッド。
    def is_quad_below_min_size(self, rect: Rect) -> bool:
        return (
            rect.right - rect.left + 1 <= self.min_size
            or rect.bottom - rect.top + 1 <= self.min_size
        )

    # 指定された`Rect`を四分割して生成された、新しい`Quad`のリストを返すメソッド。
    def split(self, rect: Rect) -> list:
        x_center = (rect.left + rect.right) // 2
        y_center = (rect.top + rect.bottom) // 2

        rects = [
            Rect(rect.left, x_center - 1, rect.top, y_center - 1),
            Rect(x_center, rect.right, rect.top, y_center - 1),
            Rect(rect.left, x_center - 1, y_center, rect.bottom),
            Rect(x_center, rect.right, y_center, rect.bottom),
        ]
        quads = [self.create_quad(rect) for rect in rects]
        return quads

    # `self.gif_frames`のフレームを繋いで、アニメーションGIFを生成するメソッド。
    def save_gif(self, output_path: str, duration: int = 10):
        self.gif_frames[0].save(
            output_path,
            save_all=True,
            append_images=self.gif_frames[1:],
            duration=duration,
            loop=0,
        )


# 使用例
quad_tree_generator = QuadTreeGenerator(input_path="./kamome.jpg", iterations=300)
quad_tree_generator.generate_quad_tree()
quad_tree_generator.save_gif(output_path="./kamome.gif")
```

## 生成例

背景がベタ塗りで、複雑度にコントラストがある画像を選ぶと上手くいくようです。

![カモメの画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3483756/89b09b02-9409-3b86-2d72-2e3b12f2fcdc.jpeg "カモメの画像")
> 出典: [Pixabay](https://pixabay.com/ja/photos/%E9%B4%8E-%E9%B3%A5-%E5%8B%95%E7%89%A9-%E3%82%AB%E3%83%A2%E3%83%A1-%E6%B5%B7%E9%B3%A5-517091/)

![カモメのGIF](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3483756/c01bdc8c-6946-97d0-115e-71ced8cb0d84.gif "カモメのGIF")

# おわりに

いかがでしたか？ぜひみなさんも好きな言語で実装してみましょう！

# 参考
https://zenn.dev/baroqueengine/articles/ccf3ac7a2c9332