G'MIC Adventures #3: (Pseudo)-Diffusion-Limited Aggregation

Today, I’d like to start a third episode of my G’MIC Adventures serie, with a quick exploration of Diffusion-Limited Aggregation: How to implement it as G’MIC scripts, and what cool things can be derived from it?

Ok, so first, what is Diffusion-Limited Aggregation ? @Reptorian already implemented it inside G’MIC a few years ago, so that’s something you may be already familiar with. But for this episode, I’m gonna re-implement it from scratch, for educational purposes (and for fun, let’s not kid ourselves :wink: ).
First, let me just copy/paste what Wikipedia tells about it:

Diffusion-limited aggregation (DLA) is the process whereby particles undergoing a random walk due to Brownian motion cluster together to form aggregates of such particles.

In practice, how does it translate with discrete images ? Basically, you start from an image where only a few pixels are set (e.g. with value 255, all others pixels are 0). For instance, let’s start with this simple circle outline:

dla: 
  400,400 => img

  # Populate image with some points.
  circle[img] 50%,50%,100,1,0xFFFFFFFF,255

Now, the principle of DLA is as follows: You pick the location of a random black pixel in this image, and you start moving a “particle” from here under a Brownian motion (basically a completely random trajectory). And you move this particle until it collides with one of the existing white pixel of the image. When it happens, you set the latest non-colliding pixel to 255 and you start again.
Simple, right?

Here’s a simple documented G’MIC code that does exactly that:

dla :
  400,400 => img

  # Populate image with some points.
  circle[img] 50%,50%,100,1,0xFFFFFFFF,255

  # Propagate points by DLA.
  eval "
    repeat (30000,it,  # Number of new pixels we want to add

      # Find a random 'empty' location.
      do (P = [ v(w#$img - 1), v(h#$img - 1) ], i(#$img,P));

      # Compute brownian motion until a collision occurs.
      repeat (inf,
        nP = P + v([-1,-1],[1,1]);  # Choose a random neighbor
        nP[0]%=w#$img; # Make sure it does not go out of image
        nP[1]%=h#$img;
        i(#$img,nP)?break(); # Collision? Stop!
        P = nP;
      );
      i(#$img,P) = 255; # Set latest empty pixel to white
      !(it%100)?run('w[img]');
    )"

And here is how the image evolve over time when you run this script:
anim_dla-ezgif.com-optimize

What’s nice about DLA is that it creates a kind of “branching” effect, as new particles tend to stick more likely to the edges of existing structures.

Now, let’s talk about some issue of this approach : You can imagine that when you don’t have many points that are already set (e.g. an image with a single point at the center), the probability that a new thrown particle collides with an existing point may become really low. It’s something we can easily verify by replacing the line

circle[img] 50%,50%,100,1,0xFFFFFFFF,255

with

=[img] 255,50%,50%  # Set only a central point

We get this nice image:


but it takes 24s of computation time, just for a 400x400 image!
(for comparison, it takes only 7.5s with the circle outline).

This is a real problem, and if we want to generate larger images in reasonable time in the future, we’re going to have to find something more efficient.
And that’ll be the aim of my second post on this subject! Stay tuned! :kissing_heart:

2 Likes

I sort particles to the closest to existing ones to speed up the effect, and generation is multithreaded. There is a non-DLA approach @garagecoder and I talked about.

Looking at the evolution of particle aggregation in my previous animation, we quickly get the feeling that the current algorithm is not efficient at all:
For each particle thrown, we try to find random collision locations with the already existing shape, whereas it would seem simpler to try to extend the shape directly (whose set of points we know a priori) in a more or less random way.

Why not trying this ? We do as before, but we also store the coordinates of each shape’s point in a dynamic array along with the image we modify.

Let’s go, this is a slight variation of the previous code:

dla :
  400,400 => img
  1,1,1,2 => pts

  # Populate image with some points.
  circle[img] 50%,50%,100,1,0xFFFFFFFF,1  # Binary image: pixel values are now 0 or 1
  eval[img] "i?da_push(#$pts,[x,y])"

  # Propagate points.
  eval "
    dPs = [ -1,-1,0,-1,1,-1,1,0,1,1,0,1,-1,1,-1,0 ]; # Table of the 8 neighboring [dx,dy] offsets

    repeat (30000,it, # Number of new pixels we want to add

      # Find a random 'non-empty' location with at least one empty neighbor.
      do (
        p = v(da_size(#$pts) - 1);
        P = I[#$pts,p],
        sum(crop(#$img,P[0] - 1,P[1] - 1,3,3))==9  # Continue if no empty neighbors!
      );

      # Pick a random empty neighbor.
      oq = v(0,7);
      repeat (8,k,
        q = (oq + k)%8;
        nP = P + dPs[2*q,2];
        !i(#$img,nP)?break();
      );

      i(#$img,nP) = 1;
      da_push(#$pts,nP);
      !(it%10)?run('w[img]');
    )"

And this animation is what we get as a result of the code above:

sp-ezgif.com-optimize(1)

Hmm… not at all what we wanted!

The shape expands too “isotropically”: particles are added to the contour little by little, but with a uniform probability law, and the result is a shape that tends to swell homogeneously
(the only good point is that incredibly fast compared to the previous algorithm :wink: ).

How can I get a branch-shaped expansion like in the original DLA algorithm?
Well, we’re gonna have to be a little more clever, and choose our points a little less “uniformly”.

To avoid this uniform swelling of the form, there are a number of more or less complicated variants that have already been studied in the literature. Here, I propose to add a simple constraint that will actually naturally create branch-like structures.

The idea is to avoid aggregating points that could form too dense a shape. Instead, we’ll try to create new points to extend sparsely populated areas (to form branches or filaments).
To do this, we will adopt the following strategy:

  • At each iteration, we won’t randomly draw a single free candidate point, but several, and we’ll calculate a score (or rather a penalty) for that candidate.
  • Finally, we’ll choose the “best” of these candidates and put it in the blank, i.e. the one with the lowest possible score.

Here’s how to adapt the above code to achieve this small variation:

dla :
  400,400 => img
  1,1,1,2 => pts

  # Populate image with some points.
  circle[img] 50%,50%,100,1,0xFFFFFFFF,1
  eval[img] "i?da_push(#$pts,[x,y])"

  # Propagate points.
  eval "
    const N = 30; # Number of candidates we pick at each iteration
    dPs = [ -1,-1,0,-1,1,-1,1,0,1,1,0,1,-1,1,-1,0 ]; # Table of the 8 neighboring [dx,dy] offsets

    xys = vector(#2*N); # XY-coordinates of each candidate
    scores = vector(#N); # Associated score

    repeat (30000,it, # Number of new pixels we want to add

      # Find the N candidates, and compute associated score.
      repeat (N,n,

        # Find a random 'non-empty' location with at least one empty neighbor.
        do (
          p = v(da_size(#$pts) - 1);
          P = I[#$pts,p],
          sum(crop(#$img,P[0] - 1,P[1] - 1,3,3))==9  # Continue if no empty neighbors!
        );

        # Pick a random empty neighbor.
        oq = v(0,7);
        repeat (8,k,
          q = (oq + k)%8;
          nP = P + dPs[2*q,2];
          nP[0]%=w#$img;
          nP[1]%=h#$img;
          !i(#$img,nP)?break();
        );

        copy(xys[2*n],nP);
        scores[n] = sum(crop(#$img,nP[0] - 12,nP[1] - 12,25,25,1));
      );

      # Get the candidate with the lowest score and set it.
      p = argmin(scores);
      P = xys[2*p,2];
      i(#$img,P) = 1;
      da_push(#$pts,P);
      !(it%100)?run('w[img]');
    )"

And we get this:

sp-ezgif.com-optimize(2)

which is not exactly what we want, but at least exhibit some “branching” behavior :slight_smile:

Now, what is missing ?

  • First, it seems the branches have two preferred orientations (horizontal & vertical) which leads us to believe that the score is not calculated correctly (in this case, non isotropically). But modulating the score calculation with a Gaussian weight can put things right.
  • There are now a few parameters to set, and we can get very different results depending on how they are set. We don’t expect to get exactly the same rendering as the original DLA.

After a few experiments, I got this, which I find even more interesting than the original DLA result:

dla :
  512,512 => img
  1,1,1,2 => pts

  # Populate image with some points.
  circle[img] 50%,50%,100,1,0xFFFFFFFF,1
  eval[img] "i?da_push(#$pts,[x,y])"

  # Generate gaussian weight for score computation.
  30,30 gaussian. 25%,25% => gaussian

  # Propagate points.
  eval "
    const N = 30; # Number of candidates we pick at each iteration
    dPs = [ -1,-1,0,-1,1,-1,1,0,1,1,0,1,-1,1,-1,0 ]; # Table of the 8 neighboring [dx,dy] offsets

    const sW = w#$gaussian;
    const sW2 = int(sW/2);
    W = crop(#$gaussian);

    xys = vector(#2*N); # XY-coordinates of each candidate
    scores = vector(#N); # Associated score

    repeat (30000,it, # Number of new pixels we want to add

      # Find the N candidates, and compute associated score.
      repeat (N,n,

        # Find a random 'non-empty' location with at least one empty neighbor.
        do (
          p = v(da_size(#$pts) - 1);
          P = I[#$pts,p],
          sum(crop(#$img,P[0] - 1,P[1] - 1,3,3))==9  # Continue if no empty neighbors!
        );

        # Pick a random empty neighbor.
        oq = v(0,7);
        repeat (8,k,
          q = (oq + k)%8;
          nP = P + dPs[2*q,2];
          nP[0]%=w#$img;
          nP[1]%=h#$img;
          !i(#$img,nP)?break();
        );

        copy(xys[2*n],nP);
        scores[n] = sum(crop(#$img,nP[0] - sW2,nP[1] - sW2,sW,sW,1)*W)*abs(4+g);
      );

      # Get the candidate with the lowest score and set it.
      p = argmin(scores);
      P = xys[2*p,2];
      i(#$img,P) = 1;
      da_push(#$pts,P);
      !(it%100)?run('w[img]');
    )"

And I get this:

sp-ezgif.com-optimize(3)

which is indeed very different from the original DLA :slight_smile:
But this is good looking to me, so I’m sticking with this version right now.
Next step will be to extend this to the 3D world !
Stay tuned! :sunglasses:

1 Like

anim3d-ezgif.com-optimize

Well, there was not much issue in extending the code above in 3D.
Not sure this is worth the copy/paste :slight_smile:

And I couldn’t resist in adding some forest moss texture to it :slight_smile:

mousse-ezgif.com-optimize

That’s (probably) the end of this G’MIC adventure.
I was a bit hasty at the end, but a lot of work has come in the meantime!

1 Like

That’s great work. I wanted to extend my own version into 3D a while ago.

How’s the execution time for your current work?

I checked mine for 400x400x1 with spread factor used, and 1000 attempts, I got .345 s.

It’s still quite slow, but just faster than the original DLA algorithm. I’m pretty sure indeed your version if way faster than mine!

I haven’t read too closely, but doesn’t the initial approach keep creating new pixels and therefore new random walks each time? Seems like you could store the random walks and sample from them. Got some other ideas, but at the moment this is a distraction from other work I’m mean to be doing so will have to come back to it :slight_smile:

Edit: also wondering if random surfaces could be used, e.g. plasma

2 Likes

That is what I did, with multithreading enabled and sort by closest walks per threads. I’m sure faster approach can exist. And I’m sure you can generate 3D trees with this algorithm.

2 Likes

Nice effect!
Here’s little me trying to get some watercolor effect out of this:
Using a 10x10 gaussian makes the branches tighter but it also renders faster. Got down to 1.2s for 60k points.
Switching the gaussian to a gradient ( y, or x*y…) gives a direction, that’s cool!
Don’t know how to change the color or opacity over time so i just used i(#$img,P) = u;. Probably needs a lerp thing that i can’t use properly…

3 Likes

099 → :doughnut:
100 → :prayer_beads:
101 → :woman_beard:

2 Likes

102 → :wind_face: :woman_beard:

1 Like

I actually like that ‘failed’ example of David of the expanding circle more than the branching.
Of course it’s a distraction of what he was trying to achieve, but still though.

1 Like

Really looks cool, David. Ice crystals is what my first thought when I saw your results. :slight_smile:

1 Like

Can’t wait for more creative and colourful uses of this (both David’s and prawn’s)!

Kind of like an expanding ring of smoldering fire.

More like mineral crystallization.

Add me to the list, I do plan to make other variant down the roads. At least gradient variant.

1 Like