部内ハッカソンでPyxelにPILのImageを読み込ませた話

# はじめに
今回紹介する方法はあくまで個人が無理やり読み込ませたものです。安全性や設計上の問題が存在している可能性は十分にあります。使用は**自己責任**でお願いします。

# 結論
1. `PIL.Image.Image`の画像データからhex形式の文字列を取得
1. `pyxel`のデフォルトパレットを参照しながらいい感じに変換
1. `pyxel.Image.set()`関数にぶち込む

これでできます！

# 経緯
部内のハッカソンで`Pyxel`に`PIL.Image.Image`形式の画像を表示したいと思い、色々と調べて見た結果、前例がなさそうだったのでコンバータを自作することにしました。

# とりあえず`Python`側を見る
まず`Python`側のインタフェースを観察したときに`pyxel.Image.set()`関数が今回の用途(プログラムから画像データを動的読み込みすること)に適していそうだとがわかったので、これを頑張って使うことにしました。
(少し`Pyxel`について調べるとわかるのですが、そもそも`Image`クラス自体がいわゆる '高度な' APIであり、公式ドキュメントに説明がありません。(すでに芳醇な終焉の香り))
とりあえず、`Python`側のインタフェースから`data`引数に画像データの16進数列を渡してあげれば行けそうなのがわかります。ということで、一旦`PIL.Image.Image.tobytes()`関数と`bytes.hex()`関数を使って、画像データの16進数列を取得して`Image.width`×`4 (RGBA)`の長さを持つ文字列が`Image.height`個入った配列に変換して渡してみました。
結果としては、読み込みに失敗しました。(正確には画像として正しく表示できませんでした)

# `Pyxel`の実装を読む
これ以上は`Pyxel`の`Rust`側を読まないとわからなかったので、読みました。
結果としては`Pyxel`は16色のデフォルトパレットを持っていて画像の各ピクセルの色と近い色をパレット内から探し、画像全体を0〜Fのインデックスに置き換えていることがわかりました。
ということで、`Rust`実装から諸々の関数と色情報を引っ張ってきていい感じにしました。

# 最終的な実装
今までのことを踏まえてコンバータの実装はこうなりました。
```python
from PIL import Image
import pyxel


class PIL2Pyxel:
    DEFAULT_PALETTE = [
        (0, 0, 0),
        (43, 51, 95),
        (126, 32, 114),
        (25, 149, 156),
        (139, 72, 82),
        (57, 92, 152),
        (169, 193, 255),
        (238, 238, 238),
        (212, 24, 108),
        (211, 132, 65),
        (233, 195, 91),
        (112, 198, 169),
        (118, 150, 222),
        (163, 163, 163),
        (255, 151, 152),
        (237, 199, 176),
    ]
    HEX_CODES = "0123456789ABCDEF"

    def __init__(self, img_data: Image.Image):
        self.__img_data = img_data.convert("RGB")
        self.pyxel_format = self.__convert()

    def __color_dist(
        self, rgb1: tuple[int, int, int], rgb2: tuple[int, int, int]
    ) -> float:
        """
        2色の色距離を求める

        Args:
            rgb1 (tuple[int, int, int]): 色1 
            rgb2 (tuple[int, int, int]): 色2

        Return:
            float: 距離
        """
        (r1, g1, b1) = rgb1
        (r2, g2, b2) = rgb2
        dx = (r1 - r2) * 0.30
        dy = (g1 - g2) * 0.59
        dz = (b1 - b2) * 0.11
        return dx * dx + dy * dy + dz * dz

    def __get_closest_color(self, rgb: tuple[int, int, int]) -> str:
        """
        色距離が一番近い、パレット色のインデックスを取得する

        Args:
            rgb (tuple[int, int, int]): 検索対象の色情報

        Return:
            str: 0~Fのインデックスを表すhexコード
        """
        min_dist = float("inf")
        closest_color = 0

        for i, color in enumerate(self.DEFAULT_PALETTE):
            dist = self.__color_dist(rgb, color)
            if dist < min_dist:
                min_dist = dist
                closest_color = i
        return self.HEX_CODES[closest_color]

    def __convert(self):
        """ゴリ押し変換関数"""
        w, h = self.__img_data.size
        hex_img = self.__img_data.tobytes().hex()
        pyxel_array = ""

        for i in range(0, len(hex_img) // 6):
            pixel = hex_img[i * 6 : (i + 1) * 6] # 1ピクセル分のデータを切り出す
            pixel_rgb = (int(pixel[:2], 16), int(pixel[2:4], 16), int(pixel[4:], 16)) # rgb値のtupleに変換
            closest_color = self.__get_closest_color(pixel_rgb) # 最近傍の色インデックスを取得
            pyxel_array += closest_color # Pyxelのデータに追加

        return [pyxel_array[i * w : (i + 1) * w] for i in range(h)] # Pyxelの受け取れる形に変形

    @staticmethod
    def load_to_pyxel(img_data: Image.Image, img_id: int, x: int, y: int):
        """
        指定した画像データをPyxelに読み込む

        Args
            img_data (Image.Image): 読み込みたい画像データ
            img_id (int): Pyxel側のimageのインデックス
            x (int): 読み込み位置のx座標
            y (int): 読み込み位置のy座標
        """
        conv = PIL2Pyxel(img_data)
        pyxel.images[img_id].set(x, y, conv.pyxel_format)


if __name__ == "__main__":
    pyxel.init(160, 120, title="PIL to Pyxel Converter")

    img = Image.open("assets/player1.png").convert("RGB")
    PIL2Pyxel.load_to_pyxel(img, 0, 0, 0)

    def update():
        if pyxel.btnp(pyxel.KEY_Q):
            pyxel.quit()

    img_size = 64

    def draw():
        pyxel.cls(0)
        pyxel.blt(0, 0, 0, 0, 0, img_size, img_size, 0)

    pyxel.run(update, draw)
```
使い方は`if __name__ == "__main__":`以下にある通りで、`Pyxel`に読み込ませたい`PIL.Image.Image`型の画像を`PIL2Pyxel.load_to_pyxel()`関数に渡せばあとは勝手にやってくれます。

最後まで読んでいただきありがとうございました。
それではまたどこかで ﾉｼ