I needed a transparent PNG image of some text to overlay text on an image. My first try looked OK, but the edges of the text seemed to be the wrong color. After some finagling, I came up with PIL code that did the right thing.

Here was the first code I used:

import Image, ImageFont, ImageDraw

fontfile = r"C:\WINDOWS\Fonts\arialbd.ttf"

words = [
    ((10, 10), "Red", "#ff0000", 30),
    ((10, 50), "Green", "#00ff00", 30),
    ((10, 90), "Blue", "#0000ff", 30),
    ((10, 130), "White", "#ffffff", 30),
    ((10, 170), "Black", "#000000", 30),
    ]

# A fully transparent image to work on.
im = Image.new("RGBA", (120, 210), (0,0,0,0))
dr = ImageDraw.Draw(im)

for pos, text, color, size in words:
    
    font = ImageFont.truetype(fontfile, size)
    dr.text(pos, text, font=font, fill=color)

im.save("badtranstext.png", "PNG")

Here's the image it produces: (If you are viewing this in IE6, you won't see the transparency)

Red, Green, Blue, with black halosRed, Green, Blue, with black halosRed, Green, Blue, with black halos

You can see that the edges of the letters are grimy. The white text should not be visible at all against the white background, but you can see the edges.

This is because when PIL draws a partially-transparent pixel at the edge of a letter, it uses the partial coverage of the shape to blend the background and foreground pixels. If the background were fully opaque, this would be the right thing to do, but with a fully transparent background like we are using, this gives the wrong color. We specified the background as fully transparent black, so for a pixel half-covered with white, PIL computes a color of half-transparent gray. It should be half-transparent white, so that the final image will be able to blend properly with any color underneath it.

Look at it another way: if I specify the background as completely transparent (alpha of 0), then it shouldn't matter what color I provide for the RGB channels. I should get the same final result if I specify (0,0,0,0) or (255,255,255,0): the background is completely transparent, it has no color at all, those values are merely placeholders. But PIL will use the color channels to assign color to the edges of the type, so the placeholder "background color" will bleed into the result.

To get the proper result, I draw each string onto a separate gray channel, then add those gray pixels into an accumulated alpha channel. Then I use the gray text to compute full-color pixels for any pixels with even a slight trace of the text on it. When combined, the alpha channel will dilute down the color of the edge pixels down to give the proper appearance.

import Image, ImageFont, ImageDraw, ImageChops

fontfile = r"C:\WINDOWS\Fonts\arialbd.ttf"

words = [
    ((10, 10), "Red", "#ff0000", 30),
    ((10, 50), "Green", "#00ff00", 30),
    ((10, 90), "Blue", "#0000ff", 30),
    ((10, 130), "White", "#ffffff", 30),
    ((10, 170), "Black", "#000000", 30),
    ]

# A fully transparent image to work on, and a separate alpha channel.
im = Image.new("RGB", (120, 210), (0,0,0))
alpha = Image.new("L", im.size, "black")

for pos, text, color, size in words:
    
    # Make a grayscale image of the font, white on black.
    imtext = Image.new("L", im.size, 0)
    drtext = ImageDraw.Draw(imtext)
    font = ImageFont.truetype(fontfile, size)
    drtext.text(pos, text, font=font, fill="white")
        
    # Add the white text to our collected alpha channel. Gray pixels around
    # the edge of the text will eventually become partially transparent
    # pixels in the alpha channel.
    alpha = ImageChops.lighter(alpha, imtext)
    
    # Make a solid color, and add it to the color layer on every pixel
    # that has even a little bit of alpha showing.
    solidcolor = Image.new("RGBA", im.size, color)
    immask = Image.eval(imtext, lambda p: 255 * (int(p != 0)))
    im = Image.composite(solidcolor, im, immask)

# These two save()s are just to get demo images of the process.
im.save("transcolor.png", "PNG")
alpha.save("transalpha.png", "PNG")

# Add the alpha channel to the image, and save it out.
im.putalpha(alpha)
im.save("transtext.png", "PNG")

This is more work, but gives the correct results. Here's the alpha channel, the color channels, and the final result:

The alpha channelThe color channelThe final result

And the result on various backgrounds:

The good transparent textThe good transparent textThe good transparent text

tagged: python, how-to, graphics» 11 reactions

Comments

[gravatar]
Edward Abrams 10:51 AM on 30 Jan 2008

Nice Ned! Similar to what I had to do in ImageMagick to make dropshadows not have funny grey edges on arbitrary colored backgrounds.

[gravatar]
scott lewis 1:06 PM on 30 Jan 2008

I don't think you need to do the pixel-by-pixel building of the colour portion of the image. It shouldn't matter whether the transparent pixels are blue or black, since they are completely transparent. So you should be able to just apply the mask to a solid colour.

Assuming that works, if you need to combine multiple text colours in a single image, you only need a rectangle of the appropriate colour for each word.

[gravatar]
Ned Batchelder 1:37 PM on 30 Jan 2008

Scott: you are right, I could simply use a rectangle of color. That might be a good optimization.

[gravatar]
Brian Hammond 12:50 AM on 31 Jan 2008

this is probably easier.. it uses pyglet and hence OpenGL to do the rendering:

from pyglet import window, font, image
from pyglet.gl import *

win = window.Window()
ft = font.load('Arial', 36)
text = font.Text(ft, 'Hello World!', color=(1,0,0,1), y=-ft.descent)
glClearColor(0,0,0,0)
win.set_size(400,300)
win.dispatch_events()
win.clear()
text.draw()
win.flip()
image.get_buffer_manager().get_color_buffer().save('some-image.png')

[gravatar]
Peter 1:02 AM on 31 Jan 2008

Artifacts like the edges you're seeing are often the result of errors in premultiplication. The problem in your case is that PIL's PNG file encoder is writing a premultiplied image (that is, with R, G and B each multiplied by A), but PNG images are required to be unpremultiplied (see http://tools.ietf.org/html/rfc2083#page-75 for details).

You can fix that by unpremultiplying the image before saving. It's possible that there's an Image mode or save option to do that for you, but you can do it manually by replacing each pixel (R, G, B, A) with (R/A, G/A, B/A, A) wherever A is nonzero. For an 8-bit/channel image, remember to multiply by 255: R = int(round(255.0 * R / A)).

[gravatar]
Fredrik 4:32 AM on 31 Jan 2008

"Artifacts like the edges you're seeing are often the result of errors in premultiplication. The problem in your case is that PIL's PNG file encoder is writing a premultiplied image (that is, with R, G and B each multiplied by A)"

No, it doesn't. PIL's RGBA mode is not pre-multiplied. The problem is, as Ned mentions, that PIL's default compositing rules doesn't do the right thing if you're compositing RGBA on top of RGBA.

(there are slightly more efficient ways to work around this, but it's really something that should be fixed in the library...)

[gravatar]
Ned Baldessin 10:33 AM on 31 Jan 2008

Excellent!

Next stop: sub-pixel anti-aliased text :)

[gravatar]
Brian Hammond 1:35 PM on 31 Jan 2008

untested:

cfg=Config(sample_buffers=1, samples=4)

Then change the window creation to include parameter

win=Window(config=cfg)

[gravatar]
Brian Hammond 3:28 PM on 31 Jan 2008

I had to add double_buffer=True in Config() and here is some fallback code if multisampling is not supported by your OpenGL driver...

(remove the '.'s -- needed since this comment engine has no preformatted mode)


from pyglet import window, font, image, clock
from pyglet.gl import *
from pyglet.window import key

def on_key_press(symbol, modifiers):
.. if symbol == key.SPACE:
.... print 'saving color buffer to disk'
.... image.get_buffer_manager().get_color_buffer().save('some-image.png')

screen = window.get_platform().get_default_display().get_default_screen()
template = Config(double_buffer=True, sample_buffers=1, samples=4)

try:
.. config = screen.get_best_config(template)
except window.NoSuchConfigException:
.. template = Config()
.. config = screen.get_best_config(template)

import pprint
pprint.pprint(config)

context = config.create_context(None)
win = window.Window(context=context)
glClearColor(0,0,0,0)

ft = font.load('Arial', 36)
text = font.Text(ft, 'Hello World!', color=(1,0,0,1), y=-ft.descent)

win.set_size(400,300)
win.push_handlers(on_key_press)

clock.set_fps_limit(5)

while not win.has_exit:
.. win.dispatch_events()
.. clock.tick()
.. win.clear()
.. text.draw()
.. win.flip()

[gravatar]
George V. Reilly 12:35 AM on 1 Feb 2008

You can make transparent PNGs that work in IE6 -- without the AlphaImageLoader CSS filter hack. Use the little-known PNG8 format, http://www.sitepoint.com/blogs/2007/09/18/png8-the-clear-winner/

I can't tell from a quick perusal of the PIL documentation if it supports PNG8, or not.

[gravatar]
George V. Reilly 8:34 PM on 10 Mar 2008

I should clarify that the AlphaImageLoader hack that I mentioned has a severe problem: it can deadlock IE6. See http://blogs.cozi.com/tech/2008/03/transparent-png.html for gory details.

Add a comment:

name
email
Ignore this:
not displayed and no spam.
Leave this empty:
www
not searched.
 
Name and either email or www are required.
Don't put anything here:
Leave this empty:
URLs auto-link and some tags are allowed: <a><b><i><p><br><pre>.