0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

結論

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

これでできます!

経緯

部内のハッカソンでPyxelPIL.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の実装を読む

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

最終的な実装

今までのことを踏まえてコンバータの実装はこうなりました。

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()関数に渡せばあとは勝手にやってくれます。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?