Why are there thin, dark lines in my SVG feTurbulence output?

Viewed 189

When experimenting with the feTurbulence filter primitive, I'm getting thin, dark lines throughout where I wouldn't expect them. They're most visible when numOctaves="1". Why are they there?

Let's say I start with the reference code from https://www.w3.org/TR/SVG11/filters.html#feTurbulenceElement (fixing it so it compiles). I call it as

turbulence(
            0,        /* color channel */,
            point,    /* {x,y} */
            1.0, 1.0, /* fBaseFreqX and Y */
            1,        /* numOctaves */
            0,        /* bFractalSum */
            0,        /* bDoStitching */
            0.0, 0.0, /* fTileX and Y */
            0.0, 0.0, /* fTileWidth and Height */
            )

(My full source is available at https://gitlab.com/AlanDeSmet/svg-1.1-feturbulence )

Iterating x and y from 0.0 through 10.0, taking 300 samples, and multiplying each sample by 256 creates a 300x300 greyscale image:

enter image description here

That's what I expect to see. It looks similar to Perlin turbulence generated by programs like

Adobe Flash (source): enter image description here

3ds Max (source): enter image description here

But if I create an SVG using feTurbulence and view it in Firefox, Chromium, or Inkscape (which I believe are 3 independent implementations), I get this:

enter image description here

Source:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg height="10" width="10" version="1.1"
   xmlns="http://www.w3.org/2000/svg"
   xmlns:svg="http://www.w3.org/2000/svg">
  <filter color-interpolation-filters="sRGB"
     id="test-turbulence" x="0" y="0" width="1" height="1">
    <feTurbulence type="turbulence" numOctaves="1" baseFrequency="1" />
    <feColorMatrix
       values="1 0 0 0 0
               1 0 0 0 0
               1 0 0 0 0
               0 0 0 0 1 " />
  </filter>
  <rect width="10" height="10" style="filter:url(#test-turbulence)" x="0" y="0" />
</svg>

(I'm using color-interpolation-filters="sRGB" to more closely match the output from my simple program. It doesn't change the structure, it just "darkens" the image.

The image has fine, dark lines throughout the image that I'm not expecting. Here is a side-by-side comparison; my use of the standard reference implementation on the left (or above) and Chromium's output (which looks identical to Firefox's and Inkscape's) on the right (or below).

enter image description here enter image description here

It seems likely to be correct to the standard as three different renderers agree, but it's not what I believe the standard reference implementation does, nor what some other programs do.

Why is there a difference between my attempt to use the standard's reference implementation and what Firefox, Chrome, and Inkscape do? Is the standard supposed to be different from what other programs implemented for Perlin turbulence? If so, what's the difference?

2 Answers

Summary

This is expected behavior. You can avoid it by using the alpha channel for turbulence, not any of the color channels.

You can use feColorMatrix to create greyscale turbulence from the alpha channel:

<filter id="turbulence-alpha" x="0" y="0" width="1" height="1">
  <feTurbulence type="turbulence" baseFrequency="0.02" />
  <feColorMatrix
     values="0 0 0 1 0
             0 0 0 1 0
             0 0 0 1 0
             0 0 0 0 1 " />
</filter>

But Why?

The behavior is surprising, but it matches the SVG specification and may be correct for certain uses.

The unexpected lines are from the alpha channel, despite having discarded it! For example, here are all four channels. Observe that the threads span all of the color channels and matches right up with the near-zero portions of the alpha channel.

enter image description here

(The SVG used to generate this is below under "Alpha Comparison SVG".)

The SVG specification (backup link) says:

Unless otherwise stated, all image filters operate on premultiplied RGBA samples. Filters which work more naturally on non-premultiplied data (feColorMatrix and feComponentTransfer) will temporarily undo and redo premultiplication as specified.

"Premultiplied" in this case is talking about premultiplied alpha, where the color channels are adjusted by the alpha. Premultiplication is good because it allows compositing and filtering to work correctly. It happens behind the scenes and you can ignore it... unless you modify the alpha channel.

The problem is that premultiplication loses data. And when alpha values approach 0 (fully transparent), the data loss is particularly severe. When feColorMatrix or feComponentTransfer "temporarily undo and redo premultiplication", the undo operation is only an approximation. That data loss manifests as unexpected lines throughout the image.

For example, given an input image whose color channels are

enter image description here

and whose alpha channel is

enter image description here

the premultiplied version of the color channels would be

enter image description here

Attemping to undo the premultipliction yields this:

enter image description here

There is damage throughout the image (just shy of 50% of the pixels mismatch), but the difference from the original is most striking when the alpha is near zero.

(These images were created by the Python code below under "Comparison Image Generator". The premul_alpha and unpremul_alpha are based on
Inkscape's implementation)

What about type="fractalNoise"?

All of the above applies to <feTurbulence type="fractalNoise">, so why isn't it a problem?

Because <feTurbulence type="fractalNoise" numOctaves="1"> is raw Perlin 2d noise, and Perlin noise is in the range −0.707 through 0.707 (backup link). It's treated as a −1 through 1 range. Remapping that range to 0 through 255, all values end up between 37 through 217. The damage is present, but because the alpha is never close enough to 0, you don't see it.

It becomes visible with type="turbulence" because Perlin turbulence uses the absolute value of the raw noise. So the range becomes 0.000 through 0.707, ultimately in the range 0 through 217. This is also why fractalNoise doesn't have any pure black while turbulence does (and why neither has any pure white).

enter image description here enter image description here

(The source for this are in "Turbulence versus Noise" below.)

Footnotes

Alpha Comparison SVG

This is the SVG comparing the four channels emitted by feTurbulence.

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
   height="230"
   width="800"
   version="1.1"
   xmlns="http://www.w3.org/2000/svg"
   xmlns:svg="http://www.w3.org/2000/svg">
  <filter id="turbulence-red" x="0" y="0" width="1" height="1">
    <feTurbulence type="turbulence" baseFrequency="0.02" />
    <feColorMatrix
       values="1 0 0 0 0
               1 0 0 0 0
               1 0 0 0 0
               0 0 0 0 1 " />
  </filter>
  <filter id="turbulence-green" x="0" y="0" width="1" height="1">
    <feTurbulence type="turbulence" baseFrequency="0.02" />
    <feColorMatrix
       values="0 1 0 0 0
               0 1 0 0 0
               0 1 0 0 0
               0 0 0 0 1 " />
  </filter>
  <filter id="turbulence-blue" x="0" y="0" width="1" height="1">
    <feTurbulence type="turbulence" baseFrequency="0.02" />
    <feColorMatrix
       values="0 0 1 0 0
               0 0 1 0 0
               0 0 1 0 0
               0 0 0 0 1 " />
  </filter>
  <filter id="turbulence-alpha" x="0" y="0" width="1" height="1">
    <feTurbulence type="turbulence" baseFrequency="0.02" />
    <feColorMatrix
       values="0 0 0 1 0
               0 0 0 1 0
               0 0 0 1 0
               0 0 0 0 1 " />
  </filter>
  <text x="100" y="220" text-anchor="middle">Red Channel</text>
  <rect x="0" y="0" width="200" height="200"
     style="filter:url(#turbulence-red)" />

  <text x="300" y="220" text-anchor="middle">Green Channel</text>
  <rect x="200" y="0" width="200" height="200"
     style="filter:url(#turbulence-green)" />

  <text x="500" y="220" text-anchor="middle">Blue Channel</text>
  <rect x="400" y="0" width="200" height="200"
     style="filter:url(#turbulence-blue)" />

  <text x="700" y="220" text-anchor="middle">Alpha Channel</text>
  <rect x="600" y="0" width="200" height="200"
     style="filter:url(#turbulence-alpha)" />
</svg>

Comparison Image Generator

This code generated the four square example images above.

#! /usr/bin/python3

from PIL import Image

def premul_alpha(color,alpha):
    temp = alpha * color + 128
    res = (temp + (temp >> 8)) >> 8
    return res

def unpremul_alpha(color, alpha):
    if alpha == 0: return color # Nonsensical operation
    res = int((255 * color + alpha/2) / alpha)
    return res

originalimg = Image.new("L",(256,256))
original_px = originalimg.load()
alphaimg = Image.new("L",(256,256))
alpha_px = alphaimg.load()
premulimg = Image.new("L",(256,256))
premul_px = premulimg.load()
restoredimg = Image.new("L",(256,256))
restored_px = restoredimg.load()
damagedimg = Image.new("L",(256,256),0)
damaged_px = damagedimg.load()


total = 0
dmg_count =0
for color in range(256):
    for alpha in range(0,256):
        original_px[color,alpha] = color;
        alpha_px[color,alpha] = alpha;
        during  = premul_alpha(color,alpha)
        premul_px[color,alpha] = during
        restored = unpremul_alpha(during,alpha)
        restored_px[color,alpha] = restored
        total += 1
        if restored != color:
            dmg_count += 1
            damaged_px[color,alpha] = 255
print(f"{dmg_count}/{total} -> {dmg_count/total}")

originalimg.save("original.png")
alphaimg.save("alpha.png")
premulimg.save("premul.png")
restoredimg.save("restored.png")
damagedimg.save("damaged.png")

Turbulence vs Noise

Noise:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
   height="200"
   width="200"
   version="1.1"
   xmlns="http://www.w3.org/2000/svg"
   xmlns:svg="http://www.w3.org/2000/svg">
  <filter id="turbulence-alpha" x="0" y="0" width="1" height="1">
    <feTurbulence type="fractalNoise" baseFrequency="0.02" />
    <feColorMatrix
       values="0 0 0 1 0
               0 0 0 1 0
               0 0 0 1 0
               0 0 0 0 1 " />
  </filter>
  <rect x="0" y="0" width="200" height="200"
     style="filter:url(#turbulence-alpha)" />
</svg>

Turbulence:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
   height="200"
   width="200"
   version="1.1"
   xmlns="http://www.w3.org/2000/svg"
   xmlns:svg="http://www.w3.org/2000/svg">
  <filter id="turbulence-alpha" x="0" y="0" width="1" height="1">
    <feTurbulence type="turbulence" baseFrequency="0.02" />
    <feColorMatrix
       values="0 0 0 1 0
               0 0 0 1 0
               0 0 0 1 0
               0 0 0 0 1 " />
  </filter>
  <rect x="0" y="0" width="200" height="200"
     style="filter:url(#turbulence-alpha)" />
</svg>

That's not quite the math that is used. This is the relevant comment from the Chromium source code:

/** About the noise types : the difference between the first 2 is just minor tweaks to the algorithm, they're not 2 entirely different noises. The output looks different, but once the noise is generated in the [1, -1] range, the output is brought back in the [0, 1] range by doing :

 *  kFractalNoise_Type : noise * 0.5 + 0.5

 *  kTurbulence_Type   : abs(noise)

Very little differences between the 2 types, although you can tell the difference visually.

https://source.chromium.org/chromium/chromium/src/+/main:third_party/skia/src/shaders/SkPerlinNoiseShader.cpp?q=SkPerlinNoiseShader&ss=chromium

Well, I don't know if it's helpful, but those threads are zero or near zero opacity areas that are converted to fully opaque when you set opacity to 1 everywhere. You can see it by adding a green background, and multiplying alpha by 128.

svg{
  background: green;
}
<svg height="600px" width="800px" viewBox="0 0 800 600">
  <filter color-interpolation-filters="sRGB"
     id="test-turbulence" x="0%" y="0%" width="100%" height="100%">
    <feTurbulence type="turbulence" numOctaves="1" baseFrequency=".02" />
    <feColorMatrix
       values="1 0 0 0 0
               1 0 0 0 0
               1 0 0 0 0
               0 0 0 128 0 " />
  </filter>
  <rect width="800" height="600" filter="url(#test-turbulence)" x="0" y="0" />
</svg>

Related