Truly transparent text with PIL

Wednesday 30 January 2008

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

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.

[gravatar]
Alessandro Sabatelli 10:06 PM on 27 May 2010

Fantastic!! I was searching all over for alpha fringe PIL. FWIW I could have used a more simple summary: Generate your image and an alpha image separately. Then add your alpha image into your image using the putalpha function. Thanks a ton!!

[gravatar]
Kevin Williams 3:57 PM on 21 Mar 2011

I know this is an old post, but so far it's been the only thing I've encountered that's useful.

I am having an infuriating time dealing with compositing RGBA+RGBA. Following something like what you have above I am able to do it when ...

1 - The background is completely solid, OR
2 - The background is completely transparent;

... Otherwise it muffs it up badly. Whether or not PIL has its own "special way" of dealing with this problem, or PIL is simply broken I am still not sure.

At this point I would hate to throw away what amounts to a couple dozen hours trying to get this to work, but let's not mince words -- if I had written it in ImageMagick it'd have worked right the first time. I'm trying to avoid using that because I need to deploy with a lot of developers with buildout and don't want a dependency hell any worse than it already is. Very frustrating.

[gravatar]
Kevin Williams 9:24 AM on 22 Mar 2011

I hate to drag this out even further, but wanted to share that I believe I have "cracked" the problem after banging my head against the keyboard for the better part of the day yesterday and some of this morning.

It is absolutely related to problems with premultiplied alpha -- sorry Fredrik. It seems that the alpha channel on the first layer is to blame.

The solution to the problem is actually straightforward in words:

* Don't create an empty layer as suggested here.

* Start with the first layer of solid color.

* Apply your alpha-transparency to the solid color layer.

* After you apply the alpha-channel, use an algorithm to REMOVE the pre-multiplied alpha from the first layer:

channel = (channel * alpha + matte_color[channel_name]) // 255

That is, you need to loop over the (r,g,b) channels. matte_color is vital to getting this right; it is the color to which you are colorizing this layer. Make sure you use the correct "channel" of the matte_color (e.g. if channel == red, then make sure you're using the "red" channel of the matte_color, too).

* Now composite each subsequent alpha-transparent layer on top of the first one.

* finally, re-apply pre-multiplied alpha to the end result immediately before saving:

if a > 0: channel = (channel * (255 + a) // 2) // a

Result: No weird color halos caused by the first layer. If you think it was bad on text, you should have seen how awful it looked on my gradient test cases.

Hope this helps save someone a lot of frustration.

[gravatar]
Lindsay Jorgensen 1:33 PM on 23 Mar 2012

I've been having this problem for a long time as well. Tried a bunch of tricks to try and minimize it.
Tried variants of these kind of arguments with various ImageChops, but this is the only way I can get a bunch of semi-transparent images from png to merge together successfully.
Apologies for function/variable naming. Artist code.
Ended up using formula from http://en.wikipedia.org/wiki/Alpha_compositing

def Ljo_Composite(img0,img1):
nArr = list(img0.getdata())
nArr = list(img1.getdata())
for ind in range(min(len(inArr),len(nArr))):
Aa=nArr[ind][3]/255.0
if(Aa != 0):
val=list(nArr[ind])
Ca=[nArr[ind][0]/255.0,nArr[ind][1]/255.0,nArr[ind][2]/255.0]
Cb=[inArr[ind][0]/255.0,inArr[ind][1]/255.0,inArr[ind][2]/255.0]
Ab=inArr[ind][3]/255.0
a=1-(1-Aa)*(1-Ab)
for j in range(3):
val[j]=(Ca[j]*Aa + (Cb[j] - Ca[j]*Aa) * Ab)/a
val[j]=int(val[j]*255)
val[3]=int(a*255)
inArr[ind]=tuple(val)
img0.putdata(inArr)
return img0

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>.