はじめに
今回紹介する方法はあくまで個人が無理やり読み込ませたものです。安全性や設計上の問題が存在している可能性は十分にあります。使用は自己責任でお願いします。
結論
-
PIL.Image.Image
の画像データからhex形式の文字列を取得 -
pyxel
のデフォルトパレットを参照しながらいい感じに変換 -
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
実装から諸々の関数と色情報を引っ張ってきていい感じにしました。
最終的な実装
今までのことを踏まえてコンバータの実装はこうなりました。
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()
関数に渡せばあとは勝手にやってくれます。
最後まで読んでいただきありがとうございました。
それではまたどこかで ノシ