Experimenting with Noise in py5

Recently @micycle presented his uniform noise library. I thought it would interesting to see how py5 noise compared, because I know it is implemented differently from processing “perlin noise”. Here is the test sketch (module mode):-

import py5

UniformNoise = py5.JClass('micycle.uniformnoise.UniformNoise')
Color = py5.JClass('java.awt.Color')
OCTAVES = 4

def settings():
    py5.size(800, 800)

def setup():
    global unoise
    sketch_title('Uniform Noise Test')
    py5.noise_detail(OCTAVES)
    py5.load_pixels()
    unoise = UniformNoise()

def draw():
    global val
    for y in range(py5.height):
        for x in range(py5.width):
            if (x < py5.width / 2):
                val = (py5.noise(x * 0.015, y * 0.015, py5.frame_count * 0.1) + 1) / 2
            else:
                val = unoise.uniformNoise(x * 0.0085, y * 0.0085, py5.frame_count * 0.01, OCTAVES, 0.5)
            py5.pixels[y * py5.width + x] = Color.HSBtoRGB(val, 1, 1)
    py5.update_pixels()

def sketch_title(title):
    py5.get_surface().set_title(title)

py5.run_sketch()

NB: here I placed the @micycle noise jar in a jar folder adjacent to sketch, also py5 noise is in range (-1…1)
And here is the result.

It should be possible to make sketch more efficient using numpy, but that’s for another time.

Checking out py5 documentation the default noise_mode is SIMPLEX_NOISE with noise detail

py5.noise_detail(octaves=4, persistence=0.5, lacunarity=2.0)

The PERLIN_NOISE mode looks even worse than the processing “perlin noise”.

4 Likes

I’m not a mathematician, so perhaps the following question is not stated correctly, but I will try to be clear.

Is it possible to test the various implementations of the noise, objectively, for statistical isotropy?

See Wikipedia: Isotropy.

If it is good noise, it should not have directional artifacts from a statistical perspective. In other words, if we took a circular sample of a portion of the noise, and rotated it by a random amount, we should be unable to discern its original orientation.

Kurt Spencer (aka KdotJPG) has clearly given this a lot of thought, and he offers users of his libraries the choice of implementations and variants. Actual choice of noise depends on the application, and for that choices are not solely made on the basis of isotropy. Performance especially for animations is a major consideration. I use his libraries for noise in my ruby-processing implementations.

1 Like

py5 uses the Python noise library, which unfortunately has not been updated in years, and may no longer be maintained. It also requires c compilation, as there are no pre-compiled wheels available on pypi (but wheels are available on conda forge). This can complicate the installation process for people who are not using Anaconda.

I may need to replace the noise library with something else. This is an ongoing issue.

Can I recommend Kurt Spencers OpenSimplex2.

This is a good suggestion, but I would prefer a Python library, for performance reasons. Do you know of any? I have found these:

But only vnoise looks like it is well maintained.

I should also note that with Python, users will be free to install and use any noise library they wish. They don’t have to use the one that comes with py5. I am trying to take the “batteries included” approach that Processing does providing functionality to users. It just needs to be done in a way that lets me reliably provide functionality over time. A library that is not well maintained means I might need to swap it out later, which would break the code of people who were using it in their projects. The one included in py5 doesn’t have to meet any criteria of “best” noise library since it is easy for people to install and use a different one if they want.

I’m not sure that you are right on performance, the java versions seem far quicker with less delay, here is my vnoise version:-

import py5
import vnoise

UniformNoise = py5.JClass('micycle.uniformnoise.UniformNoise')
Color = py5.JClass('java.awt.Color')
OCTAVES = 4

def settings():
    py5.size(800, 800)

def setup():
    global unoise, vector_noise
    sketch_title('Uniform Noise Test')
    py5.load_pixels()
    unoise = UniformNoise()
    vector_noise = vnoise.Noise()

def draw():
    global val
    for y in range(py5.height):
        for x in range(py5.width):
            if (x < py5.width / 2):
                val = (vector_noise.noise3(x * 0.015, y * 0.015, py5.frame_count * 0.1) + 1) / 2
            else:
                val = unoise.uniformNoise(x * 0.0085, y * 0.0085, py5.frame_count * 0.01, OCTAVES, 0.5)
            py5.pixels[y * py5.width + x] = Color.HSBtoRGB(val, 1, 1)
    py5.update_pixels()

def sketch_title(title):
    py5.get_surface().set_title(title)

py5.run_sketch()

For completeness I compiled the OpenSimplex2S (smooth version) and this is my py5 code:-

import py5

UniformNoise = py5.JClass('micycle.uniformnoise.UniformNoise')
OpenSimplex2S = py5.JClass('monkstone.noise.OpenSimplex2S')
Color = py5.JClass('java.awt.Color')
OCTAVES = 4


def settings():
    py5.size(800, 800)

def setup():
    global unoise, simplex
    sketch_title('Uniform Noise Test')
    py5.load_pixels()
    unoise = UniformNoise()
    simplex = OpenSimplex2S(py5.millis())

def draw():
    global val
    for y in range(py5.height):
        for x in range(py5.width):
            if (x < py5.width / 2):
                val = (simplex.noise3_Classic(x * 0.015, y * 0.015, py5.frame_count * 0.1) + 1) / 2
            else:
                val = unoise.uniformNoise(x * 0.0085, y * 0.0085, py5.frame_count * 0.01, OCTAVES, 0.5)
            py5.pixels[y * py5.width + x] = Color.HSBtoRGB(val, 1, 1)
    py5.update_pixels()

def sketch_title(title):
    py5.get_surface().set_title(title)

py5.run_sketch()

And the result as expected is quite promising


Furthermore performance exceeds that of vnoise.

It is interesting to experiment with these noise functions. I played around with them and did some tests.

Before getting into what I found, I want to point out that

  • this took me at least 10 tries to get right
  • half of this wasn’t obvious to me and I had to google a bunch of stuff
  • along the way I discovered a bug in the way py5 currently uses its noise library
  • the results surprised me also

To be most efficient I wanted to do this without for loops. Generally, Python performs best when you use what are called vectorized operations. A vectorized operation lets you apply an operation to all of the numbers in an array at the same time. Of course, somewhere there is looping happening; the difference is that the vectorized operation will do the loop in optimized C code instead of Python.

Here’s what I came up with:

import numpy as np
from PIL import Image
import noise
import vnoise

OpenSimplex2S = JClass('py5.noise.OpenSimplex2S')

w, h = 1200, 800

vector_noise = vnoise.Noise()
xgrid, ygrid = np.meshgrid(np.linspace(0, 12 // 3, num=w // 3, dtype=np.float32), np.linspace(0, 12, num=h, dtype=np.float32))
noise_array = np.full((h, w // 3, 3), 255, dtype=np.uint8)
noise_array2 = noise_array.copy()
noise_array3 = noise_array.copy()

def setup():
    size(w, h)
    global open_simplex
    open_simplex = OpenSimplex2S(millis())

def draw():
    # noise library, which is what py5 currently uses, but I am calling it directly because of a bug I found writing this ;)
    noise_array[:, :, 0] = 255 * (np.vectorize(noise.pnoise3)(ygrid, xgrid, frame_count * 0.1, octaves=1) + 1) / 2
    image(Image.fromarray(noise_array, mode='HSV').convert('RGB'), 0, 0)
    # vnoise library
    noise_array2[:, :, 0] = 255 * (vector_noise.noise3(ygrid, xgrid, frame_count * 0.1, octaves=1, grid_mode=False) + 1) / 2
    image(Image.fromarray(noise_array2, mode='HSV').convert('RGB'), 400, 0)
    # open simplex
    noise_array3[:, :, 0] = 255 * (np.vectorize(open_simplex.noise3_Classic)(ygrid, xgrid, frame_count * 0.1) + 1) / 2
    image(Image.fromarray(noise_array3, mode='HSV').convert('RGB'), 800, 0)

I did the best I could to make sure I was comparing apples to apples. It looks to me like the OpenSimplex2S object is using only one octave in noise3_Classic. The granularity of the texture looks to me like one octave and similar to the other two, and I couldn’t find anything in the code that seems to dispute that. But if you think I made a mistake there, please let me know.

I’m using the np_vectorize tool, which takes any old function and does the best it can to optimize it for broadcasting operations over arrays. The vnoise library doesn’t need to use that.

The PIL Image HSV to RGB conversion I didn’t know about and had to google. It seemed like the most efficient way to convert from one color space to another, but there might be better ways. Again, this is a vectorized operation, and will be much faster than doing this one pixel at a time in a for loop. Inside the PIL library, in C code, it is doing a loop to convert each pixel.

To get performance statistics I can use the built-in tools. While the sketch is running, I type:

profile_draw()

and then a few minutes later,

print_line_profiler_stats()

These are the results:

Timer unit: 1e-06 s

Total time: 604.128 s
File: <ipython-input-4-415b4354e79f>
Function: draw at line 8

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     8                                           def draw():
     9                                               # noise library, which is what py5 currently uses, but I am calling it directly because of a bug I found writing this ;)
    10       293  329755860.0 1125446.6     54.6      noise_array[:, :, 0] = 255 * (np.vectorize(noise.pnoise3)(ygrid, xgrid, frame_count * 0.1, octaves=1) + 1) / 2
    11       293   19325199.0  65956.3      3.2      image(Image.fromarray(noise_array, mode='HSV').convert('RGB'), 0, 0)
    12                                               # vnoise library
    13       293   37371843.0 127549.0      6.2      noise_array2[:, :, 0] = 255 * (vector_noise.noise3(ygrid, xgrid, frame_count * 0.1, octaves=1, grid_mode=False) + 1) / 2
    14       293    5231848.0  17856.1      0.9      image(Image.fromarray(noise_array2, mode='HSV').convert('RGB'), 400, 0)
    15                                               # open simplex
    16       293  207530770.0 708296.1     34.4      noise_array3[:, :, 0] = 255 * (np.vectorize(open_simplex.noise3_Classic)(ygrid, xgrid, frame_count * 0.1) + 1) / 2
    17       293    4912409.0  16765.9      0.8      image(Image.fromarray(noise_array3, mode='HSV').convert('RGB'), 800, 0)

Neat, huh? It shows you how much time is spent on each line. There’s a lot happening in that table. The clearest line is the “% Time” column. It shows that it is spending 54% of the time using the first method, which is py5’s current noise implementation. It spends 34% of the time using the OpenSimplex java code. And it spends 6% of the time using vnoise.

I knew the noise library would be slower than vnoise but I did not think it would be that slow.

Now this does not mean that the open simplex library is slow or no good; I’m confident that most of that time was spent moving a few numbers at a time from Python to Java and then one number returned from Java to Python, assigned to the array. Py5 is not very efficient when you make thousands of little calls to Java like that, so it is best to avoid that wherever you can.

There’s another technique that could be used here to make OpenSimplex much, much faster than the other two options, and that is by creating a hybrid Java-Python application, which py5 currently supports. Basically you’d write some Java code to accept an entire array of inputs, and then in Java you would do the loop over the calls to noise3_Classic for each value in the array, and then you would return the entire array back to Python. This approach is more effort to code, but I believe it would be faster than vnoise. That’s too much to explain here though but I will be documenting this eventually.

Does all of this make sense? I can provide more detail anywhere if you need it. I’m here to help!

Oh, I should add that I used the py5 kernel to run that code so that’s why you don’t see an import py5 at the top and the functions are not preceded with py5. everywhere. If you want to run it in the regular python kernel, you can easily convert it by adding the import statement and put py5. in the necessary places.

I use quite a bit of hybrid ruby/java in my ruby-processing projects, indeed I wrap the OpenSimplex code as a ruby module. Similarly I know to avoid too much java to ruby conversion (and vice versa). Unfortunately there is no vectorization option in ruby, and my experience in python is limited to what I’ve done recently and some experiments with pyprocessing back in the day.

PS: the main reason for side to side comparison was for visual quality of noise and not for performance. What drew me to OpenSimplex2 was the quality of the sketches by Etienne Jacob (aka Bleuje) he has a nice new web site for his tutorials.

Cool, I understand. I will do what I can to help you and others learn more about this. Vectorization is indeed a different programming abstraction that has its own hurdles and challenges. It took me time to get used to it.

I’ll be writing a blog post about this soon…hopefully this week. I think it is an important subject that I’d like people to understand, both in the context of py5 and in general.

I agree, I also liked the visual quality of OpenSimplex2 noise! I clearly saw a difference in the side by side comparison. I’m glad you told me about this, I might use it in future projects.

I tracked down the bug that caused me to write this:

# noise library, which is what py5 currently uses, but I am calling it directly because of a bug I found writing this ;)

Turns out the bug is in the noise library, not py5.

py5 randomly picks a noise seed between 0 and 1024, and according to the library documentation, all of those values should be allowed. However, the noise quality degrades significantly if that seed is above 400 or so.

Also I shouldn’t be randomly picking a noise seed like that because then the results are not deterministic from one Sketch execution to the next.

More reasons for me rework py5’s noise functionality.

If you want to replace (np.vectorize(noise.pnoise3) with the py5’s noise function, remove the import noise at the top, in draw() change (np.vectorize(noise.pnoise3) to noise, and in setup(), add noise_seed(0).