Generating mazes from pictures, or “Masking Entropy”

Entropy is a measure of chaos. Higher entropy equals more unpredictability, lower entropy equals less disorder.

Mazes are collections of paths usually connecting one or more entrances to one or more exits. A regular maze looks like this:

The first maze we’re going to create.

If you look closely, you can see that some areas of the maze are slightly more orderly: straight corridors instead of ninety degrees turns and bends. But, overall, the maze looks pretty chaotic.

Below is a “bitmap mask”:

The black areas represent a ‘0’, while the white areas represent a ‘1’. This picture only uses two colors, but generally speaking bitmap masks can use every possible channel value to encode all different kinds of information.

The process of masking is what lets you overlay a picture on top of another by multiplying its transparency by the color values of a mask:

Sunflower photo from: https://pixabay.com/

The concept can be extended to many other uses, such as encoding height maps for 3-D games as 2-D projections:

Noise functions, like Perlin noise and Simplex noise are often used to generate natural-looking terrain.

But that’s enough premise. We’re going to talk about mazes, about entropy and about masks.

Let’s get to work.

Part 0: Generating a maze

In order to achieve our goal, we must first understand how a maze is generated. For the purpose of this post, I’m going to use the recursive backtracker algorithm.

This is a depth-first search algorithm, meaning that it starts at a given node (of a graph, but you can imagine it as a grid) and ‘naively’ explores until it hits a dead end. Then it backtracks to a point where there are still unexplored neighbors left, and proceeds with its exploring until each cell has been visited at least once.

Let’s see how we can turn these words into an actual maze. From now on, all the code is going to be Python 2.x and the coding environment will be Processing (in Python mode).

Here’s my implementation, and our first example: 00 — SimpleMaze.

That’s a lot of code! But fear not, all the important bits are located inside Maze.generate(). The algorithm itself is pretty straightforward, and I hope that my comments do it justice. Feel free to yell at me if I should explain it in more depth here.

Anyway, pay special attention to this line:

# Shuffle the neighbours list by sorting it with random values and pick the first element             
chosenCell = sorted(unexploredNeighbours, key=lambda c: random(1))[0]

The algorithm has to pick a random unexplored neighbor. There are a few ways to achieve this, but I chose to sort the list with a custom function, the key argument. This function lets us return whatever number we deem fit for each element, and the list is then sorted against those values. By returning a random number we ensure that each element will be sorted randomly, for now.

Let’s run the code.

Our first maze! Isn’t it lovely?

Great, that looks like a maze! Now we’re ready to step up our game.

Part 1: Introducing a bias

What if we wanted a mostly-horizontal maze instead of a completely random one? What we’d need is a bias. A bias is a tendency, and we want our maze to tend to have horizontal corridors.

Let’s start by sorting each neighbor by its vertical position:

chosenCell = sorted(unexploredNeighbours, key=lambda c: c.position[1])[0]

This gives us a perfectly straight “maze”:

A maze with zero entropy, because of its deterministic nature.

This maze has zero entropy, because each cell is part of a straight corridor. In contrast, our first maze had maximum entropy, because it was very chaotic.

Let’s throw in a random number ranging from 0 to somewhat more than 1. When that number will be higher than 1, we’ll have a random turn. Otherwise, we’ll have a straight corridor. This number is our bias, and we can tune it in various ways to achieve different results.

chosenCell = sorted(unexploredNeighbours, key=lambda c: c.position[1] + random(1.5))[0]

Lo and behold, mission accomplished! And we only changed one line of code.

A maze with horizontal bias!

Full example, as unnecessary as it is: 01 — HorizontalBias

Part 2: Manipulating entropy

Now that we know how to introduce a bias, it will be trivial to manipulate the maze’s entropy as we please. For example, what about using trigonometry to generate a ‘wavy’ maze?

Wait, give me a second— I want to edit our generate() function first. I’ll add a parameter named sortfun that will default to random(1) and which will act as a custom sorting function so that we’ll be able to pass it directly by argument.

@staticmethod
def generate(w, h, sortfun=lambda c: random(1)):
...
chosenCell = sorted(unexploredNeighbours, key=sortfun)[0]
...

That’s how I like it.

Back to our problem, we can now pass a sine-based formula to generate() during setup:

def setup():
global maze
    ...
    customsort = lambda c: random(2) * sin(c.position[0] / 10) + c.position[0]
maze = Maze.generate(width // scalingFactor, height // scalingFactor, sortfun=customsort)

Which yields this:

A maze with sinusoidal entropy!

We can also make a checkered pattern:

N = 2
customsort = lambda c: (c.position[0] * c.position[1]) % N + c.position[0]
N = 2
N = 10

There’s really a lot of room for creativity! Here are some more examples:

A maze built around a subtle, repeating diamond pattern.. { sin(c.position[1]) * cos(c.position[0]) }
A rectangular ‘table-like’ pattern. { (c.position[1] % 2 + c.position[0] % 10) }
This one is really mesmerizing. { noise(c.position[0] / 100, c.position[1] / 100) }
Same as above, double size.

Here’s the code so far: 02 — Noisy.

We have achieved control over chaos. We’re ready for the final step.

Part 3: Putting it all together

So far we’ve learned about mazes, bitmap masks and controlled randomness. Let’s try to combine these separate pieces of knowledge into something a bit more interesting.

We’re going to need a gray-scale picture, our mask.

A four-color mask. White is chaos and black is calm.

Here we have four colors. We want a maze subdivided in four sections, each more chaotic than the last.

All the edits will happen inside setup():

def setup():
global maze

size(512, 512)
strokeWeight(1)

mazeMask = loadImage('data/maze-mask.png')
mazeMask.resize(width // scalingFactor, height // scalingFactor)

getbrightness = lambda c: brightness(mazeMask.get(c.position[0], c.position[1])) / 255
customsort = lambda c: c.position[0] + getbrightness(c) * random(4)

maze = Maze.generate(mazeMask.width, mazeMask.height, sortfun=customsort)

What am I doing here?

  1. I load the mask from the data subdirectory.
  2. I scale it down to the size of our maze.
  3. I define a helper function, getbrightness which returns the brightness of a pixel on our mask in the 0–1 range.
  4. I define the customsort function in which links vertical bias to pixel brightness across 4 possible values (ergo random(4)).
  5. I generate the maze just like before.

Et voilà:

We generated a maze from a picture!

But we’re not done yet. Remember part 2? What if I told you that you could plug any of those functions into the finished program?

customsort = lambda c: (c.position[0] * c.position[1]) % int(1 + 4 * getbrightness(c)) * 4

I don’t think it’s still fair to call it ‘bias’, not with such mathematical results:

Woah, dude.

Our sort functions have evolved into what you could call textures.

A black/white mask of a flower shape.

Applying a mask over a texture gives you full control over what your maze will look like.

customsort = lambda c: random(1) * getbrightness(c)
A flower-shaped maze.

I wish I had more to talk about, but that’s it. You’re a quantum-bender now, use your powers wisely.


I hope you enjoyed reading this post as much I enjoyed writing it. I’ll leave you with the completed program and a last picture.

03 — PicToMaze

Until next time!