In this post, we'll have a look at stereograms, in particular autostereograms.
This post will introduce two methods of creating illusions of depth. The first one consists of using a pattern and shifting it repeatedly along the horizontal axis. The second one uses a depth map and a pattern and shifts the individual pixels of the pattern according to a depth map. In this case, the pattern used is often a random pattern, hence the term "random dot stereogram".
In the first part of this blog post, we will use horizontally repeated patterns to create an illusion of depth. In the second part of this post, we will turn to the depth map type stereogram.
Before getting started, we will build some tools to work with images:
- a function that returns a new image in a given size, with a given background
- a function that displays an image
Let's get started with a blank image builder. We will use the scikit-image style and thus model images with numpy arrays. Let's import the packages we will need along the way:
import numpy as np import matplotlib.pyplot as plt import skimage, skimage.io %matplotlib inline
plt.rcParams['figure.dpi'] = 150
def blank_image(shape=(600, 800, 4), rgba=(255, 255, 255, 0)): "Returns a blank image, of size defined by shape and background color rgb." return np.ones(shape, dtype=np.float) * np.array(rgba) / 255.
img = blank_image()
Now that we have an image, let's write a function that displays it.
def display(img, colorbar=False): "Displays an image." plt.figure(figsize=(10, 10)) if len(img.shape) == 2: i = skimage.io.imshow(img, cmap='gray') else: i = skimage.io.imshow(img) if colorbar: plt.colorbar(i, shrink=0.5, label='depth') plt.tight_layout()
/Users/kappamaki/anaconda/lib/python3.4/site-packages/skimage/io/_plugins/matplotlib_plugin.py:74: UserWarning: Low image dynamic range; displaying image with stretched contrast. warn("Low image dynamic range; displaying image with "
Now, let's address the tiling of patterns. First, we load the pattern that we will work with througout this post: a coin.
coin = skimage.io.imread('files/coin-icon.png')
What does this coin look like?
Let's now write a function that inserts a pattern onto an existing image.
def insert_pattern(background_img, pattern, location): """Inserts a pattern onto a background, at given location. Returns new image.""" img = background_img.copy() r0, c0 = location r1, c1 = r0 + pattern.shape, c0 + pattern.shape if r1 < background_img.shape and c1 < background_img.shape: img[r0:r1, c0:c1, :] = skimage.img_as_float(pattern) return img
Let's test this:
test_img = insert_pattern(img, coin, (10, 20)) display(test_img)
From there, we can write a horizontal tile function that maps a pattern a repeated number of times on an image.
def tile_horizontally(background_img, pattern, start_location, repetitions, shift): "Tiles a pattern on a background image, repeatedly with a given shift." img = background_img.copy() for i in range(repetitions): r, c = start_location c += i * shift img = insert_pattern(img, pattern, location=(r, c)) return img
Let's test this:
test_img = tile_horizontally(img, coin, (10, 20), 3, 128) display(test_img)
Now that we have the basics going, let's imitate the examples found on the wikipedia page for autostereograms: three rows of coins, each with its own shift.
img = blank_image(shape=(450, 800, 4)) img = tile_horizontally(img, coin, (10, 10), 6, shift=130) img = tile_horizontally(img, coin, (10 + 150, 10), 5, shift=150) img = tile_horizontally(img, coin, (10 + 2*150, 10), 5, shift=140) display(img)
If you look at this image with the so called wall-eyed look, I expect you to see the middle row to be pushed back inside the picture while the top and bottom row stand out. Can you see it?
The conclusion from this experiment is that we can say that closely packed objects look more close to us than wider spaced ones.
Now, the question is: does it work vertically as well? We can actually answer that question: try to visualize the 3d pattern and then rotate your screen. To me, the illusion of depth goes away quite quickly, a slight tilt is sufficient to break the impression of depth.
This result is already interesting. But what about depth map based stereograms?
The wikipedia article on autostereograms describes the algorithm for creating depth map based stereograms like this:
A computer program can take a depth map and an accompanying pattern image to produce an autostereogram. The program tiles the pattern image horizontally to cover an area whose size is identical to the depth map. Conceptually, at every pixel in the output image, the program looks up the grayscale value of the equivalent pixel in the depth map image, and uses this value to determine the amount of horizontal shift required for the pixel.
One way to accomplish this is to make the program scan every line in the output image pixel-by-pixel from left to right. It seeds the first series of pixels in a row from the pattern image. Then it consults the depth map to retrieve appropriate shift values for subsequent pixels. For every pixel, it subtracts the shift from the width of the pattern image to arrive at a repeat interval. It uses this repeat interval to look up the color of the counterpart pixel to the left and uses its color as the new pixel's own color.
This is, to say the least, not a very clear explanation. Let's try to deconstruct this. We need two things:
- a pattern
- a depth-map
First, let's create a pattern. We will use a random pattern made of gray values.
def make_pattern(shape=(16, 16), levels=64): "Creates a pattern from gray values." return np.random.randint(0, levels - 1, shape) / levels
Let's test this:
pattern = make_pattern(shape=(128, 64))
Now, we would like to have a depth map. Let's create a depth map using a simple circle.
def create_circular_depthmap(shape=(600, 800), center=None, radius=100): "Creates a circular depthmap, centered on the image." depthmap = np.zeros(shape, dtype=np.float) r = np.arange(depthmap.shape) c = np.arange(depthmap.shape) R, C = np.meshgrid(r, c, indexing='ij') if center is None: center = np.array([r.max() / 2, c.max() / 2]) d = np.sqrt((R - center)**2 + (C - center)**2) depthmap += (d < radius) return depthmap
depthmap = create_circular_depthmap(radius=150)
Now that we're here, we will also create a function that normalizes a depthmap into the [0, 1] range:
def normalize(depthmap): "Normalizes values of depthmap to [0, 1] range." if depthmap.max() > depthmap.min(): return (depthmap - depthmap.min()) / (depthmap.max() - depthmap.min()) else: return depthmap
Finally, let's apply the algorithm.
def make_autostereogram(depthmap, pattern, shift_amplitude=0.1, invert=False): "Creates an autostereogram from depthmap and pattern." depthmap = normalize(depthmap) if invert: depthmap = 1 - depthmap autostereogram = np.zeros_like(depthmap, dtype=pattern.dtype) for r in np.arange(autostereogram.shape): for c in np.arange(autostereogram.shape): if c < pattern.shape: autostereogram[r, c] = pattern[r % pattern.shape, c] else: shift = int(depthmap[r, c] * shift_amplitude * pattern.shape) autostereogram[r, c] = autostereogram[r, c - pattern.shape + shift] return autostereogram
Let's test this!
autostereogram = make_autostereogram(depthmap, pattern)
What about an inverted one?
autostereogram = make_autostereogram(depthmap, pattern, invert=True)