16

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.

Community
  • 1
  • 1
Damian Yerrick
  • 4,602
  • 2
  • 26
  • 64
  • 1
    A "round to closest color" given an arbitrary color palette pretty much requires a pixel-by-pixel technique, so is probably best written in a non-interpreted language like C. Parts of PIL are written in C — its open source — so you could extend it rather than writing a whole new extension module from scratch. – martineau Apr 03 '15 at 14:06
  • @martineau Dithering to a custom palette also requires pixel-by-pixel technique, yet PIL can do it. Rounding to closest color in the web or adaptive palette also requires pixel-by-pixel technique, yet PIL can do it. It just can't round to closest color in a custom palette. If I were to fork PIL, I would have to buy one machine of each platform in order to maintain binaries of the fork for each platform. – Damian Yerrick Apr 03 '15 at 14:52
  • PIL probably does most or all of those thing in the portion of it that is written in C. You wouldn't have to formally fork it, just grab the current sources and make a custom version of it for the platform(s) you're using. – martineau Apr 03 '15 at 15:56
  • @martineau The GitHub action to make a copy of the current sources in your own account is called "fork". – Damian Yerrick Sep 11 '17 at 14:42

2 Answers2

18

Pillow 6 incorporates pull request 3699, merged on 2019-03-11, which adds the dither argument to the ordinary quantize() method. Prior to Pillow 6, the following was needed:

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 1
  • paletteimage (optional): An ImagingCore with a palette

The 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. Because it relies on an ostensibly private method, it may not work in future versions of Pillow. By then, however, we can expect Pillow pre-6 to have passed out of use, as both Debian "bullseye" (stable in mid-2021) and Ubuntu "focal" (LTS in mid-2020) package Pillow 7 or newer.

#!/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

    # Really old versions of Pillow (before 4.x) have _new
    # under a different name
    try:
        return silf._new(im)
    except AttributeError:
        return silf._makeself(im)

# putpalette() input is a sequence of [r, g, b, r, g, b, ...]
# The data chosen for this particular answer represent
# the four gray values in a game console's palette
palettedata = [0, 0, 0, 102, 102, 102, 176, 176, 176, 255, 255, 255]
# Fill the entire palette so that no entries in Pillow's
# default palette for P images can interfere with conversion
NUM_ENTRIES_IN_PILLOW_PALETTE = 256
num_bands = len("RGB")
num_entries_in_palettedata = len(palettedata) // num_bands
palettedata.extend(palettedata[:num_bands]
                   * (NUM_ENTRIES_IN_PILLOW_PALETTE
                      - num_entries_in_palettedata))
# Create a palette image whose size does not matter
arbitrary_size = 16, 16
palimage = Image.new('P', arbitrary_size)
palimage.putpalette(palettedata)

# Perform the conversion
oldimage = Image.open("School_scrollable1.png")
newimage = quantizetopalette(oldimage, palimage, dither=False)
newimage.show()
Damian Yerrick
  • 4,602
  • 2
  • 26
  • 64
  • `1 if dither else 0` is just `dither` or if you want `int(dither)` – Jean-François Fabre Apr 19 '20 at 15:20
  • @Jean-FrançoisFabre It'd have to be `int(bool(dither))` in case `dither` is something other than an instance of `bool`. – Damian Yerrick Apr 19 '20 at 19:51
  • Thanks for this answer @DamianYerrick but I can't make it working. Could you explain why you need to create a `palimage` of size exactl of `(16,16)` ? And why you multiply the palettedata by `64`? – desmond13 Dec 10 '21 at 15:40
  • The (16, 16) is arbitrary. I chose it because size is a required parameter. The day I wrote the answer, the first size that came to mind happened to have the same area in pixels as entries in a Pillow palette. I multiplied the palette data by 64 because there are 256 colors in a Pillow palette, and the list has only enough entries to describe 4 colors, and 256 / 4 = 64. – Damian Yerrick Dec 11 '21 at 16:54
6

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.
Lady_
  • 61
  • 1
  • 1