I am trying to convert an RGB image in PNG format to use a specific indexed palette using the Pillow library (Python Image Library, PIL). But I want to convert using the "round to closest color" method, not dithering, because the image is pixel art and dithering would distort the outlines of areas and add noise to areas that are intended to be flat.
I tried Image.Image.paste()
, and it used the four specified colors, but it produced a dithered image:
from PIL import Image
oldimage = Image.open("oldimage.png")
palettedata = [0, 0, 0, 102, 102, 102, 176, 176, 176, 255, 255, 255]
newimage = Image.new('P', oldimage.size)
newimage.putpalette(palettedata * 64)
newimage.paste(oldimage, (0, 0) + oldimage.size)
newimage.show()
I tried Image.Image.quantize()
as mentioned in pictu's answer to a similar question, but it also produced dithering:
from PIL import Image
palettedata = [0, 0, 0, 102, 102, 102, 176, 176, 176, 255, 255, 255]
palimage = Image.new('P', (16, 16))
palimage.putpalette(palettedata * 64)
oldimage = Image.open("School_scrollable1.png")
newimage = oldimage.quantize(palette=palimage)
newimage.show()
I tried Image.Image.convert()
, and it converted the image without dithering, but it included colors other than those specified, presumably because it used either a web palette or an adaptive palette
from PIL import Image
oldimage = Image.open("oldimage.png")
palettedata = [0, 0, 0, 102, 102, 102, 176, 176, 176, 255, 255, 255]
expanded_palettedata = palettedata * 64
newimage = oldimage.convert('P', dither=Image.NONE, palette=palettedata)
newimage.show()
How do I automatically convert an image to a specific palette without dithering? I would like to avoid a solution that processes each individual pixel in Python, as suggested in John La Rooy's answer and comments thereto, because my previous solution involving an inner loop written in Python has proven to be noticeably slow for large images.
The parts of PIL implemented in C are in the PIL._imaging
module, also available as Image.core
after you from PIL import Image
. Current versions of Pillow give every PIL.Image.Image
instance a member named im
which is an instance of ImagingCore
, a class defined within PIL._imaging
. You can list its methods with help(oldimage.im)
, but the methods themselves are undocumented from within Python.
The convert
method of ImagingCore
objects is implemented in _imaging.c
. It takes one to three arguments and creates a new ImagingCore
object (called Imaging_Type
within _imaging.c
).
mode
(required): mode string (e.g. "P"
)dither
(optional, default 0): PIL passes 0 or 1paletteimage
(optional): An ImagingCore
with a paletteThe problem I was facing is that quantize()
in dist-packages/PIL/Image.py
forces the dither
argument to 1. So I pulled a copy of the quantize()
method out and changed that. This may not work in future versions of Pillow, but if not, they're likely to implement the "extended quantizer interface in a later version" that a comment in quantize()
promises.
#!/usr/bin/env python3
from PIL import Image
def quantizetopalette(silf, palette, dither=False):
"""Convert an RGB or L mode image to use a given P image's palette."""
silf.load()
# use palette from reference image
palette.load()
if palette.mode != "P":
raise ValueError("bad mode for palette image")
if silf.mode != "RGB" and silf.mode != "L":
raise ValueError(
"only RGB or L mode images can be quantized to a palette"
)
im = silf.im.convert("P", 1 if dither else 0, palette.im)
# the 0 above means turn OFF dithering
# Later versions of Pillow (4.x) rename _makeself to _new
try:
return silf._new(im)
except AttributeError:
return silf._makeself(im)
palettedata = [0, 0, 0, 102, 102, 102, 176, 176, 176, 255, 255, 255]
palimage = Image.new('P', (16, 16))
palimage.putpalette(palettedata * 64)
oldimage = Image.open("School_scrollable1.png")
newimage = quantizetopalette(oldimage, palimage, dither=False)
newimage.show()
I took all these and made it faster, added notes for you to understand & converted to pillow instead of pil. Basically.
import sys
import PIL
from PIL import Image
def quantizetopalette(silf, palette, dither=False):
"""Convert an RGB or L mode image to use a given P image's palette."""
silf.load()
# use palette from reference image made below
palette.load()
im = silf.im.convert("P", 0, palette.im)
# the 0 above means turn OFF dithering making solid colors
return silf._new(im)
if __name__ == "__main__":
import sys, os
for imgfn in sys.argv[1:]:
palettedata = [ 0, 0, 0, 255, 0, 0, 255, 255, 0, 0, 255, 0, 255, 255, 255,85,255,85, 255,85,85, 255,255,85]
# palettedata = [ 0, 0, 0, 0,170,0, 170,0,0, 170,85,0,] # pallet 0 dark
# palettedata = [ 0, 0, 0, 85,255,85, 255,85,85, 255,255,85] # pallet 0 light
# palettedata = [ 0, 0, 0, 85,255,255, 255,85,255, 255,255,255,] #pallete 1 light
# palettedata = [ 0, 0, 0, 0,170,170, 170,0,170, 170,170,170,] #pallete 1 dark
# palettedata = [ 0,0,170, 0,170,170, 170,0,170, 170,170,170,] #pallete 1 dark sp
# palettedata = [ 0, 0, 0, 0,170,170, 170,0,0, 170,170,170,] # pallet 3 dark
# palettedata = [ 0, 0, 0, 85,255,255, 255,85,85, 255,255,255,] # pallet 3 light
# grey 85,85,85) blue (85,85,255) green (85,255,85) cyan (85,255,255) lightred 255,85,85 magenta (255,85,255) yellow (255,255,85)
# black 0, 0, 0, blue (0,0,170) darkred 170,0,0 green (0,170,0) cyan (0,170,170)magenta (170,0,170) brown(170,85,0) light grey (170,170,170)
#
# below is the meat we make an image and assign it a palette
# after which it's used to quantize the input image, then that is saved
palimage = Image.new('P', (16, 16))
palimage.putpalette(palettedata *32)
oldimage = Image.open(sys.argv[1])
oldimage = oldimage.convert("RGB")
newimage = quantizetopalette(oldimage, palimage, dither=False)
dirname, filename= os.path.split(imgfn)
name, ext= os.path.splitext(filename)
newpathname= os.path.join(dirname, "cga-%s.png" % name)
newimage.save(newpathname)
# palimage.putpalette(palettedata *64) 64 times 4 colors on the 256 index 4 times, == 256 colors, we made a 256 color pallet.