I've been working on a way to generate an initial distribution of seed points for a Voronoi stippling program that I played with a while ago. The problem is that for the final image to look good you really need a lot of points, and when you have a lot of points they are very slow to converge to a solution. The process I used in the past is to generate a few points and allow them to be processed, split them in two, process them and so on and so on. Each time the number of points is doubled and the solution is eventually found but it's still not great. An improvement I thought I could try is to linearize the image via a Pseudo-Hilbert (PH) curve and apply a rolling threshold to the data and place points at locations where the counter rolls over. You may see a major optimisation that I've know all along but haven't implemented. I'll give you a clue, it a PH curve really necessary? Find out at the end of the post.😉
The implementation of the threshold is a little abstract so I'll just explain the general concept. First of all we always work on an inverse grey scale version of the image as we are distributing black dots and the background is white. Imagine that the sum of all the pixels in the image is equal to 1,000,000 and you want 2000 points. This means that each point represents 500. So as you travel along the PH curve you add the pixel values and every time you accumulate another 500 place a point.
The points generated from this process are then iterated on via the Voronoi stippling process to produce a result. Another small feature I've added is a dithering step. For a certain number of iterations a random displacement is added to each point. This can be seen in the GIF below as a jiggling of the points. What is the purpose of this? Imagine a jar of jelly beans that won't quite close. What do you do? You pick the jar up and shake it to settle the contents. That's what we're doing here. What you end up with is a number of points that when randomly perturbed, return to their original position. i.e. a stable arrangement.
The results in the GIF aren't that great. The main reason for this is that I've kept the features large so you can see what's happening. If you really wanted to process this image the best way to do it is to increase the size of the image and then process it. When the calculated Voronoi regions are small and they only contain a few pixels, due to quantization issues the points won't move as the centroid calculations will always yield the same result. So if you notice herding of points in the final image try enlarging the image and reprocessing it.
|Distribution of Seed Stippling Points on a Pseudo-Hilbert Curve|
|Test Image Used in Adrian Secord's Paper|
In the image below I've started modestly with 4000 points to test that everything is working properly, and as you can see, everything is working. On closer inspection though, you can see that the dynamic range, for want of a better term, of the image has been reduced. In other words, the areas that are meant to be dark aren't as dark as they're meant to be, and in comparison, the areas that are meant to be light aren't as light.
|4000 points - power 1|
The dynamic range problem can be solved by pre-compensating the image. Specifically raising each pixel to a power or exponent. In the image below the value of each pixel has been squared before processing. I've referred to this as a power parameter. I've re-scaled the resulting data back to a range of 0-255 just for consistency but it's really not required.
|4000 points - power 2|
|8000 points - power 1.9|
|20000 points - power 1.6|
I think that looks pretty spectacular. Of course I've had to resize the image and scale it down a bit so downloading this page isn't a 100 MB exercise, so there's a little bit of quality degradation, but the original vector files are even better.
The point of this post however is to discuss the PH curve approximation for the seed points and how close it gets us to the final image. What does the initial distribution of seed points look like before any processing?
|20000 points - power 1.6 - no processing|
The structure is immediately visible, but there is a fuzziness to the image. This is more apparent when looking at an enlarged section. The points are distributed on a grid so you will see more lines and square corners in the arrangement.
|Close Up Section of Seed Point Distribution|
So what happens after processing? The points are allowed to find their own space and curved features start to appear. I can't explain the exact reasoning but in sections the points form curved lines running in multiple directions. If you can't see what I'm talking about, go and have a look at something called a Fermat Spiral (also a way to distribute points to evenly cover an area) and you'll see what I mean. In all things just look more pleasing to the eye.
|Close Up Section of Points After Processing|
I'm sorry but I'm not releasing the code on this one just yet. Why? Because it's terrible. There are very few comments and a lot of sections where code has been commented out. I know the theory is to develop in public and let the code be scrutinised, but I try to make a program so that it's not only functional but it also explains the concept I'm trying to describe to the user. At this point I think it would just confuse people.
It's also hard to develop things cleanly when you don't exactly know where they are going to go. Unlike a lot of programming where you have a clearly defined goal and a general way to get there. I start not usually knowing either. Think of this type of coding as a science experiment. I'll try different things and see what the results are, change things until I get what I want and then go back and clean up a section and lock it down.
I will release this soon though. There are couple things to fix but it's mostly there. If for some reason I don't post the code, let me know and I'll help you out.
Here is the optimization I didn't implement. You don't need a PH curve. Just add a white border to the source image to make it a square so that the length of a side is a power of 2 and then use an actual Hilbert curve and crop the result. They are way more efficient to generate than a PH curve. I did it this way because I was interested in the problem of covering arbitrary rectangular regions with a curve. As this blog is a labour of love I reserve the right to go off on the occasional tangent to satisfy my curiosity.