[Rod Stephens Books]
Index Books Python Examples About Rod Contact
[Mastodon] [Bluesky]
[Build Your Own Ray Tracer With Python]

[Beginning Database Design Solutions, Second Edition]

[Beginning Software Engineering, Second Edition]

[Essential Algorithms, Second Edition]

[The Modern C# Challenge]

[WPF 3d, Three-Dimensional Graphics with WPF and C#]

[The C# Helper Top 100]

[Interview Puzzles Dissected]

Title: Provide antialiasing with PIL and Python

[PIL images with and without antialiasing]

PIL (Python Imaging Library) is fairly weak compared to other drawing systems. One of its more annoying omissions is antialiasing.

This post explains anti-aliasing and describes one technique you can use to provide antialiasing when you draw PIL images.

Antialiasing

Aliasing is a jagged appearance you get when you draw shapes. It occurs when a pixel should contain only part of a shape but the drawing system must assign it a single color.

Antialiasing is the process of using shades of color to make shapes appear smooth. You cannot make a pixel partially one color and partially another, but you can use a shade that combines the two colors. For example, suppose a line passes through a pixel so that pixel should be half black (the line) and half white (the background color). You can't divide the pixel into two colors, but you can make it the average of the two colors: gray.

For example, the picture below shows close-ups of the two pictures at the top of this post. In the picture on the left, you can see how aliasing effects make the result look jagged. The picture on the right uses shades of color to blend the edges of the shapes to make them appear smoother.

[Close-ups of PIL images with and without antialiasing]
One way to provide antialiasing is to use supersampling. In supersampling, you divide each pixel into a sampling of subpixels and then color the shapes as usual. You then assign each of the original pixels the average of the colors of its subpixels. For example, suppose you divide each pixel into 4 subpixels. Now suppose a particular pixel has three white subpixels and one black subpixel. Then the larger pixel's color should be 3 * white + 1 * black = light gray. (In a real program, you average the subpixels' red, green, and blue color components. If you're using transparency, you would also average their alpha components.)

The result will be a smoother result like the one on the right in the picture above.

Antialiasing in PIL

Unfortunately, PIL doesn't provide antialiasing. 😔 Fortunately, you can use a workaround that's pretty easy. 🙂 Okay, relatively easy. At least it's easier than writing your own drawing system from scratch! Here's what you do.
  1. Pick a supersampling scale. Let's give it the clever name scale. You will logically divide each pixel into this number of subpixels vertically and horizontally. For example, if scale is 2, then each pixel will be logically divided into 2 × 2 = 4 subpixels. Usually scale = 2 provides a good result, although it's not hard to use a larger scale if you like.
  2. Create a PIL image with width and height increased by a factor of scale. For example, if you want a 100 × 200 pixel result and you want scale = 2, make the image 200 × 400 pixels.
  3. Draw on the image, multiplying everything by scale. Multiply the X and Y positions of everything by scale. Also increase line thicknesses, font sizes, and everything else by scale.
  4. After you're done drawing, resize the image by a factor of 1 / scale so it has the desired final size.
By default the image resizing algorithm uses something similar to antialiasing to make the resized image smooth. (It actually uses a bicubic resampling filter, but it gives us the smooth result we want, so a rose by any other name and all that.)

The hard part is drawing everything at an increased scale. Depending on your drawing, that might be tricky. For instance, you need to scale line thicknesses, which can be hard if you're not using standard drawing methods. (For example, if you're drawing pixels one at a time.) You may also have trouble getting fonts to scale exactly.

It helps if you know you're going to do this when you start writing the code so you can build a scale factor into things from the start. Then be sure to test at a couple of different scales to verify that you're drawing correctly.

draw_image

The following draw_image method provides the antialiasing.

def draw_image(self, width, height, scale): # Create the image. wid = width * scale hgt = height * scale result_image = Image.new("RGB", (wid, hgt), (255, 255, 255)) # Create a Draw object to draw on the image. result_dr = ImageDraw.Draw(result_image) # Draw some dashed shapes. self.draw_some_shapes(result_dr, wid, hgt, scale) # Resize the image to 1:1 scale if necessary. if scale > 1: wid = int(wid / scale) hgt = int(hgt / scale) result_image = result_image.resize((wid, hgt)) # Save the result_image (just to show we can). result_image.save(f'result{scale}.png') # Convert the image into a PhotoImage for tkinter to display. return ImageTk.PhotoImage(result_image)

This method first creates an RGB image at an enlarged scale. It makes an ImageDraw object to draw on the image and then calls draw_some_shapes to do the actual drawing.

Next, if the scale factor isn't 1, the code uses the image's resize method to shrink it to its desired final size. (This is where the antialiasing occurs.)

The code saves the result into a file (mostly for use in the enlarged pictures above), converts the image into a PhotoImage that tkinter can understand, and returns the result.

draw_some_shapes

The following draw_some_shapes method does the actual drawing.

def draw_some_shapes(self, dr, wid, hgt, scale=1): '''Draw some shapes.''' margin = 10 # Diamond. thickness = 5 * scale xmargin = 10 * scale ymargin = 30 * scale points = [ (wid / 2, ymargin), (wid - xmargin, hgt / 2), (wid / 2, hgt - ymargin), (xmargin, hgt / 2), ] dr.polygon(points, outline='green', width=thickness) # Star. thickness = 1 * scale bbox = (margin, margin, wid - margin, hgt - margin) points = get_star_points(-math.pi / 2, num_points=5, skip=2, bbox=bbox) dr.polygon(points, outline='blue', fill='lightblue', width=thickness) # Ellipse. thickness = 3 * scale rx = 30 * scale ry = 50 * scale cx = wid / 2 cy = hgt / 2 bbox = (cx - rx, cy - ry, cx + rx, cy + ry) dr.ellipse(bbox, outline='red', fill='yellow', width=thickness)

This method just draws a diamond, star, and ellipse. It shouldn't be too confusing; the only really novel thing here is the fact that it scales everything.

For information about drawing stars, see my post Draw stars in Python.

Conclusion

Perhaps PIL will someday include antialiasing, but until then you can use this technique. The idea is simple: Draw everything enlarged and then shrink the result to the desired size. The details aren't too bad if you plan ahead and make your drawing code scale everything.

Download the example to see additional details and to experiment. For example, try using a larger scale factor and see if the result is an improvement over a scale factor of 2.

For more information image processing in Python, see my Manning liveProject Algorithm Projects with Python: Image Processing. It explain how to do things like:
• Rotation• Scaling• Stretching
• Brightness enhancement• Contrast enhancement• Cropping
• Remapping colors• Image sharpening• Embossing
• Color enhancement• Sharpening• Gray scale
• Black and white• Sepia tone• Other color scales
© 2024 Rocky Mountain Computer Consulting, Inc. All rights reserved.