Code / Graphics

Optical Illusion Buttons in Python

I wrote a short python script to generate optical illusion buttons that can’t be read very well unless you tilt the screen or image to a very low angle, almost edge-on, yielding images like this:

NPR COOL DAD ROCK.png

If you’re doing it right, you should see the phrase, “NPR COOL DAD ROCK.”

The code is below, to generate an 18-character-or-less button. There are two things to be aware of: the only two dependencies are PIL and NumPy, and if you’re not on a Debian-based Linux system, you may need to adjust a line of code near the top to find the font you want. Try experimenting with parameters and fonts, some definitely work better than others. Narrow fonts tend to look pretty good. I’d love to optimize for speed, but it only takes about 3 or 4 seconds to run on my machine. This should work with either Python 2 or 3 (tested with 3.5). This repo is now hosted on GitHub here.

from PIL import ImageFont
from PIL import Image
from PIL import ImageDraw
from PIL import ImageOps
from numpy import sqrt

illusion_text = "NPR COOL DAD ROCK"
text_color = (0, 0, 0)
background_color = (255, 255, 255)
img_side = 1024

font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-ExtraLight.ttf"
charmax = 18
font_size_guess = 7*(30-len(illusion_text))
crop_width_x = 12
crop_width_y = 3
num_rotations = 6
darkness_threshold = 108

if len(illusion_text) <= charmax:
    pass
else:
    print('WARNING: Text too long. Exiting.')
    raise SystemExit(0)

img_size = (img_side, img_side)
img_size_text = (img_side, img_side)

raw_img = Image.new("RGB", img_size_text, background_color)
img = Image.new("RGBA", img_size, background_color)
circle_img = Image.new("RGBA", img_size, background_color)
full_image = Image.new("RGBA", img_size, background_color)
draw = ImageDraw.Draw(raw_img)

# step through font sizes to find optimal font for box
for font_trial in range(font_size_guess-30, font_size_guess+30):
    possible_font = ImageFont.truetype(font_path, font_trial)
    raw_img = Image.new("RGB", img_size_text, background_color)
    draw = ImageDraw.Draw(raw_img)
    draw.text((crop_width_x, crop_width_y), illusion_text, text_color, font=possible_font)

    # find bounding box of text by inversion
    inverted = ImageOps.invert(raw_img)
    possible_boundingbox = (inverted.getbbox()[0] - crop_width_x, \
                            inverted.getbbox()[1] - crop_width_y, \
                            inverted.getbbox()[2] + crop_width_x, \
                            inverted.getbbox()[3] + crop_width_y)
    if possible_boundingbox[2] - possible_boundingbox[0] < img_side-2*crop_width_x:
        boundingbox = possible_boundingbox
        font_size = font_trial
    else:
        break

raw_img = Image.new("RGB", img_size_text, background_color)
draw = ImageDraw.Draw(raw_img)
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-ExtraLight.ttf", font_size)
draw.text((crop_width_x, crop_width_y), illusion_text, text_color, font=font)
inverted = ImageOps.invert(raw_img)
raw_img = raw_img.crop(boundingbox)
scaled_img = raw_img.resize((img_side, img_side), Image.BICUBIC)
img.paste(scaled_img, (0, 0))

# map points in the square image to points in a circle
# turn light grey and white to alpha channel. Blacken dark grays.
pixdata = img.load()
for y in range(img.size[1]):
    for x in range(img.size[0]):
        if pixdata[x, y] == (255, 255, 255, 255):
            pixdata[x, y] = (255, 255, 255, 0)
        elif pixdata[x, y][1] >= darkness_threshold:
            pixdata[x, y] = (255, 255, 255, 0)
        elif pixdata[x, y][1] <= darkness_threshold:
            pixdata[x, y] = (0, 0, 0, 255)

circle_img.paste(img, (0, 0))

pixdata2 = circle_img.load()
for x in range(img_side):
    Ysize = 2 * sqrt((img_side / 2) ** 2 - (x - (img_side / 2)) ** 2)
    for y in range(img_side):
        Yoffset = int((img_side-Ysize)/2.)
        Y = Yoffset + int((Ysize/img_side)*y)
        pixdata2[x, Y] = pixdata[x, y]
        if sqrt((x-img_side/2)**2 + (y-img_side/2)**2) >= img_side/2 - 2:
            pixdata2[x, y] = (255, 255, 255, 0)

for i in range(num_rotations):
    this_circle = circle_img.rotate(i*180/num_rotations)
    full_image.paste(this_circle, (0, 0), this_circle)

pixdata = full_image.load()
for y in range(full_image.size[1]):
    for x in range(full_image.size[0]):
        if pixdata[x, y] == (255, 255, 255, 0):
            pixdata[x, y] = (255, 255, 255, 255)

full_image.save(illusion_text+".png")

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s