Truly transparent text with PIL

Wednesday 30 January 2008This is almost 17 years old. Be careful.

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 halos
Red, Green, Blue, with black halos
Red, 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 channel
The color channel
The final result

And the result on various backgrounds:

The good transparent text
The good transparent text
The good transparent text

Comments

[gravatar]
Nice Ned! Similar to what I had to do in ImageMagick to make dropshadows not have funny grey edges on arbitrary colored backgrounds.
[gravatar]
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]
Scott: you are right, I could simply use a rectangle of color. That might be a good optimization.
[gravatar]
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]
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]
"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]
Excellent!

Next stop: sub-pixel anti-aliased text :)
[gravatar]
untested:

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

Then change the window creation to include parameter

win=Window(config=cfg)
[gravatar]
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]
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]
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]
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]
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]
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:

Ignore this:
Leave this empty:
Name is required. Either email or web are required. Email won't be displayed and I won't spam you. Your web site won't be indexed by search engines.
Don't put anything here:
Leave this empty:
Comment text is Markdown.