WorldEditAdditions: The story of the //convolve
command
Sometimes, one finds a fascinating new use for an algorithm in a completely unrelated field to its original application. Such is the case with the algorithm behind the //convolve
command in WorldEditAdditions which I recently blogged about. At the time, I had tried out the //smooth
command provided by we_env
, but The //smooth
command smooths out rough edges in the terrain inside the defined WorldEdit region, but I was less than satisfied with it's flexibility, configurability, and performance.
To this end, I set about devising a solution. My breakthrough in solving the problem occurred when I realised that the problem of terrain smoothing is much easier when you consider it as a 2D heightmap, which is itself suspiciously like a greyscale image. Image editors such as GIMP already have support for smoothing with their various blur filters, the most interesting of which for my use case was the gaussian blur filter. By applying a gaussian blur to the terrain heightmap, it would have the effect of smoothing the terrain - and thus //convolve
was born.
If you're looking for a detailed explanation on how to use this command, you're probably in the wrong place - this post is more of a developer commentary on how it is implemented. For an explanation on how to use the command, please refer to the chat command reference.
The gaussian blur effect is applied by performing an operation called a convolution over the input 2D array. This function is not specific to image editor (it can be found in AI too), and it calculates the new value for each pixel by taking a weighted sum of it's existing value and all of it's neighbours. It's perhaps best explained with a diagram:
All the weights in the kernel (sometimes called a filter) are floating-point numbers and should add up to 1. Since we look at each pixels neighbours, we can't operate on the pixels around the edge of the image. Different implementations have different approaches to solving this problem:
- Drop them in the output image (i.e. the output image is slightly smaller than the input) - a CNN in AI does this by default in most frameworks
- Ignore the pixel and pass it straight through
- Take only a portion of the kernel and remap the weights to that we can use that instead
- Wrap around to the other side of the image
For my implementation of the //convolve
command, the heightmap in my case is essentially infinite in all directions (although I obviously request a small subset thereof), so I chose option 2 here. I calculate the boundary given the size of the kernel and request an area with an extra border around it so I can operate on all the pixels inside the defined region.
Let's look at the maths more specifically here:
Given an input image, for each pixel we take a multiply the kernel by each pixel and it's neighbours, and for each resulting matrix we sum all the values therein to get the new pixel value.
The specific weightings are of course configurable, and are represented by a rectangular (often square) 2D array. By manipulating these weightings, one can perform different kinds of operations. All of the following operations can be performed using this method:
- Blur, of varying different descriptions
- Edge detection
- Sharpen
- Emboss
- And others I'm sure
Currently, WorldEditAdditions supports 3 different kernels in the //convolve
command:
- Box blur - frequently has artefacts
- Gaussian Blur (additional parameter: sigma, which controls the 'blurriness') - the default, and also most complicated to implement
- Pascal - A unique variant I derived from Pascal's Triangle. Works out as slightly sharper than Gaussian Blur without having artifacts like box blur.
After implementing the core convolution engine, writing functions to generate kernels of defined size for the above kernels was a relatively simple matter. If you know of another kernel that you think would be useful to have in WorldEditAdditions, I'm definitely open to implementing it.
For the curious, the core convolutional filter can be found in convolve.lua. It takes 4 arguments:
- The heightmap - a flat ZERO indexed array of values. Think of a flat array containing image data like the HTML5 Canvas
getImageData()
function. - The size of the heightmap in the form
{ x = width, z = height }
- The kernel (internally called a matrix), in the same form as the heightmap
- The size of the kernel in the same form as the size of the heightmap.
Reflecting on the implementation, I'm pretty happy with it within the restrictions of the environment within which it runs. As you might have suspected based on my explanation above, convolutionally-based operations are highly parallelisable (there's a reason they are used in AI), and so there's a ton of scope for at using multiple threads, SIMD, and more - but unfortunately the Lua (5.1! It's so frustrating since 5.4 is out now) environment in Minetest doesn't allow for threading or other optimisations to be made.
The other thing I might do something about soon is the syntax of the command. Currently it looks like this:
//convolve <kernel> [<width>[,<height>]] [<sigma>]
Given the way I've used this command in-the-field, I've found myself specifying the kernel width and the sigma value more often than I have found myself changing the kernel. With this observation in hand, it would be nice to figure out a better syntax here that reduces the amount of typing for everyday use-cases.
In a future post about WorldEditAdditions, I want to talk about a number of topics, such as the snowballs
algorithm in the //erode
. I also potentially may post about the new //noise2d
command I've working on, but that might take a while (I'm still working on implementing a decent noise function for that, since at the time of typing the Perlin noise implementation I've ported is less than perfect).