PILで256色以下のパレット持つ画像を扱いだしているのだが、webで検索して見つかるのはフルカラー画像を扱うサンプルばかりだ。どういう使いかってだかよくわからないので、公式のドキュメントから関連した命令やプロパティをあさって、それぞれの動作を試してみる。numpyを使った処理については扱わない。
https://pillow.readthedocs.io/en/stable/reference/Image.html

画像は例によってこれを拝借する。サイズをサイズを256×256に、カラーはフルカラーのままもしくは32色のPNGに調整してある。
イメージモード
“RGBA” “RGB” “CMYK” などと対応する文字列。パレット画像では “P” だ。アルファ情報付きで “PA” というモードもある。Pモードで特定のパレット(0?)を透明色扱いもできるのではないかと予想。
https://pillow.readthedocs.io/en/stable/handbook/concepts.html#concept-modes
Image メソッド
Image.getcolors(maxcolors=256)
>P image will return the index of the color in the palette.
パレット数と同じ長さの配列が返ってくる。要素は使用ドット数とパレット番号のタプル。タプルの要素0を全部足し合わせると確かに総ドット数(256×256 = 65536)になった。
colors = img1.getcolors()
print(colors)
count = 0
for i in range(len(colors)):
count += colors[i][0]
print(count)
# [(3336, 0), (10054, 1), (2494, 2), (2305, 3), (2271, 4), (5084, 5), (877, 6), (4315, 7), (2466, 8), (1366, 9), (1350, 10), (2867, 11), (857, 12), (2509, 13), (472, 14), (2079, 15), (2298, 16), (874, 17), (1651, 18), (1397, 19), (384, 20), (1463, 21), (1589, 22), (2732, 23), (1490, 24), (231, 25), (729, 26), (2161, 27), (646, 28), (883, 29), (1735, 30), (571, 31)]
# 65536Image.getpalette(rawmode=’RGB’)
デフォルトの引数では、パレット数の3倍の長さの配列が返ってくる。R、G、Bの要素が繰り返し並んでいる。それぞれの色を画像にして並べてみたものが以下である。確かにパレット色が取れているようだ。rawmode引数は他にRのみなどを受け付けた。

Image.getpixel(xy)
フルカラー画像ではRGB値のタプル(int, int, int)が返ってくるが、パレット画像の場合パレット番号int型1つが返ってきた。
Image.putpixel(xy, value)
ドットの描画。位置のタプルとint型のパレット番号を指定する。
Image.histogram(mask=None, extrema=None)
histogram = img1.histogram()
print(histogram)
max_y = max(histogram)
img2 = Image.new("RGB", (len(histogram), 256))
for x in range(len(histogram)):
for y in range(256):
col = (255, 255, 255) if y < histogram[x] / (max_y / 256) else (0, 0, 0)
img2.putpixel((x, 255 - y), col)
# [3336, 10054, 2494, 2305, 2271, 5084, 877, 4315, 2466, 1366, 1350, 2867, 857, 2509, 472, 2079, 2298, 874, 1651, 1397, 384, 1463, 1589, 2732, 1490, 231, 729, 2161, 646, 883, 1735, 571, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]パレットモードでは長さ256の配列が返ってきた。要素は対応するパレット番号の利用ドット数のようだ。つまりgetcolorsに非常に近い。
上記コードで作成した画像は以下となる。32ドット分のみデータが存在する。

Image.putpalette(data, rawmode=’RGB’)
パレットの配列を全て書き換える。
palette = img1.getpalette()
for i in range(len(palette)):
palette[i] = 255 - palette[i]
img1.putpalette(palette)上記コードではカラーが反転する。

Image.quantize(colors=256, method=None, kmeans=0, palette=None, dither=Dither.FLOYDSTEINBERG)
https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.quantize
画像を減色してパレット画像に変換する。フルカラー画像に対してもパレット画像に対しても適用可能。convertメソッドの詳細版と思えばいいか。
カラー数指定は利用カラーパレット数。(256指定であれば問題なく動くが、数値によってはエラーになる事があった。現在エラーにならないので気のせいかもしれない。)
methodは減色のアルゴリズム指定。デフォルトはMEDIANCUT。kmeansはアルゴリズムがMEDIANCUTの際の処理回数(=クオリティ)指定。数が大きい方が遅いがパレットの選定が綺麗になる模様。
パレットを指定するとそのパレットを用いて減色するらしい。未検証。おそらく今後使うので検証はその際に。
ディザ指定については後述の通り、2つの値どちらを指定しても変わりがなかった。フルカラーからパレット、またはフルカラー・グレースケールから2値への変換時に有効とのこと。

画像は左から、フルカラーまま、MEDIANCUT、MAXCOVERAGE、FASTOCTREE、MEDIANCUT(kmeans=100)の変換結果を並べたもの。上段はディザあり下段はディザなし。下記は画像生成に使ったコード。

同様に色数を8色に指定したもの。
def show_image(img: Image, multiplier=1) -> None:
img = img.resize((img.size[0] * multiplier, img.size[1] * multiplier), Image.NEAREST)
img.show()
def concat_images(images: list, direction="horizontal") -> Image:
w: int = 0
h: int = 0
for i in images:
if direction == "horizontal":
w += i.size[0]
else:
h += i.size[1]
if direction == "horizontal":
h = max([i.size[1] for i in images])
else:
w = max([i.size[0] for i in images])
img = Image.new("RGB", (w, h))
x: int = 0
y: int = 0
for i in images:
if direction == "horizontal":
img.paste(i, (x, y))
x += i.size[0]
else:
img.paste(i, (x, y))
y += i.size[1]
return img
def main():
img0: Image = Image.open(base_path / "images/rgb_fullcolor.png")
# img1: Image = Image.open(base_path / "images/palette32.png")
images = []
for diz in (Image.Dither.FLOYDSTEINBERG, Image.Dither.NONE):
img1 = img0.quantize(
colors=32, method=Image.Quantize.MEDIANCUT, dither=diz)
img2 = img0.quantize(
colors=32, method=Image.Quantize.MAXCOVERAGE, dither=diz)
img3 = img0.quantize(
colors=32, method=Image.Quantize.FASTOCTREE, dither=diz)
img4 = img0.quantize(
colors=32, method=Image.Quantize.MEDIANCUT, kmeans=100, dither=diz)
images.append(concat_images([img0, img1, img2, img3, img4]))
show_image(concat_images(images, direction="vertical"))アルゴリズム指定の詳細。
https://pillow.readthedocs.io/en/stable/reference/Image.html#quantization-methods
アルファ付き画像では、FASTOCTREEしかアルゴリズムに指定できない。また、自分の環境では、libimagequantアルゴリズムは未サポートだった。
print(features.check_feature(feature="libimagequant"))
# -> Falsemethod、kmeans、それぞれの詳細は以下のページに詳しかった。素晴らしいですネ。
Pythonで画像の減色をする
https://water2litter.net/rum/post/python_pillow_quantize/
減色アルゴリズム[量子化/メディアンカット/k平均法] https://www.petitmonte.com/math_algorithm/subtractive_color.html
Image.remap_palette(dest_map, source_palette=None)
num_palette: int = int(len(img1.getpalette()) / 3)
remap = list(range(num_palette))
remap2 = remap.copy()
remap2.reverse()
print(img1.histogram())
img1 = img1.remap_palette(remap2)
print(img1.histogram())
show_image(img1)パレットの並びを入れ替える。上記コードでは画像自体に変化はないが、
[3336, 10054, 2494, 2305, 2271, 5084, 877, 4315, 2466, 1366, 1350, 2867, 857, 2509, 472, 2079, 2298, 874, 1651, 1397, 384, 1463, 1589, 2732, 1490, 231, 729, 2161, 646, 883, 1735, 571, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[571, 1735, 883, 646, 2161, 729, 231, 1490, 2732, 1589, 1463, 384, 1397, 1651, 874, 2298, 2079, 472, 2509, 857, 2867, 1350, 1366, 2466, 4315, 877, 5084, 2271, 2305, 2494, 10054, 3336, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]Image.reduce(factor, box=None)
画像サイズを小さくする。リサイズやこのメソッドはパレットと直接関係ないが、挙動が気になる。調査結果としてはパレットモードではこのメソッドが使えずエラーになる模様。resizeは動いた。
Image プロパティ
Image.mode
”P” が戻った。
Image.palette
PIL.ImagePalette.ImagePalette 型のオブジェクトが戻ってきた。
palette = img1.getpalette()
img_palette: ImagePalette.ImagePalette = img1.palette
print(img_palette.getcolor((palette[3], palette[4], palette[5])))
# -> 1getcolorメソッドにRGB値のタプルを渡すと対応するパレット番号が返ってきた。見つからない場合は-1ではなく32が返ってきた。空いているパレット番号だが分かりずらい。
Dither
>Used to specify the dithering method to use for the convert() and quantize() methods.
コンバートとQuantize時のディザモード指定。現状、値としては、NONE と FLOYDSTEINBERG しかない模様。デフォルト値は後者。
https://pillow.readthedocs.io/en/stable/reference/Image.html#dither-modes

しかし手元で試した限り、どちらの指定でもコンバートでは差が出なかった。
Palettes
>Used to specify the pallete to use for the convert() method.
変換するパレットのタイプ指定だが、WEBかADAPTIVEかの2択なのでADAPTIVEしか使わなそう。
https://pillow.readthedocs.io/en/stable/reference/Image.html#palettes
convertによる画像モードの変換
img0: Image = Image.open(base_path / "images/rgb_fullcolor.png")
img1 = img0.convert("P", palette=Image.ADAPTIVE, colors=4)上記はフルカラー画像を開いてquantizeではなくconvertメソッドにてパレット四色に変換するコード、下記画像はその結果。

パレット画像を色数したパレット画像にconvertしても画像に変化はなかった。
img1: Image = Image.open(base_path / "images/palette32.png")
img2 = img1.convert("P", palette=Image.ADAPTIVE, colors=2)および、パレット画像をフルカラーに変換する際にカラー数を指定しても無効だった。
img1: Image = Image.open(base_path / "images/palette32.png")
img2 = img1.convert("RGB", palette=Image.ADAPTIVE, colors=2)convertでパレット画像のパレット数を減らすにはとりあえず以下のように一度RGB画像に変換してから再度パレット化すれば実現できた。
img1: Image = Image.open(base_path / "images/palette32.png")
img1 = img1.convert("RGB")
img1 = img1.convert("P", palette=Image.ADAPTIVE ,colors=2)
まとめ
- パレット画像でもRGBやRGBAに変換してしまえば普通に扱える。
パレット画像が欲しい場合も、フルカラーでの処理後に再度パレット化したほうがおそらく大抵は楽だろう。 - パレットモードでは各画像のgetpixel値はrgb数値ではなくパレット番号を返す。
- パレット変更操作はややこしいが、getpaletteしてRGB値をいじってputpaletteすれば色味が変わる。
- パレット数を減らすやり方は、いろいろある。
- quantizeメソッドのパレット指定は別途調査。
- ディザ指定ワークしてるんだろうか?
- 生データ(numpy利用)については今回未検証。
何かあれば随時追記。





