5

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?

Alan De Smet
  • 1,699
  • 1
  • 16
  • 20
  • https://gitlab.com/AlanDeSmet/svg-1.1-feturbulence/-/blob/master/turb.c#L54 has to loop till one of the gradients is non-zero. Otherwise s will be 0 and you'll be dividing by 0 – Robert Longson Aug 09 '21 at 20:12
  • That gitlab project exists solely to experiment with the SVG feTurbulence specification, and as such I'm using the standard's reference implementation, with as few changes as possible. That includes the danger of all zero gradients. I should add some notes so anyone stumbling across it knows it's not suitable for production use. – Alan De Smet Aug 18 '21 at 16:10
  • 1
    The standard has an errata somewhere that specifies this. – Robert Longson Aug 18 '21 at 16:22
  • 1
    https://lists.w3.org/Archives/Public/www-svg/2015Jan/0014.html – Robert Longson Aug 18 '21 at 16:39

2 Answers2

5

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>
Alan De Smet
  • 1,699
  • 1
  • 16
  • 20
  • 1
    This is fantastic detective work Alan - nice job! – Michael Mullany Aug 12 '21 at 17:48
  • Michael: Assuming the bounty you offered was intended for this answer, if I understand correctly it will _not_ automatically apply to this answer because the answer predates the bounty. You might need to explicitly do something? (I'm not deeply familiar with bounties, so I may be wrong.) – Alan De Smet Aug 18 '21 at 15:39
1

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>
Michael Mullany
  • 30,283
  • 6
  • 81
  • 105
  • Your answer doesn't get to what I am trying to understand. I think my original question was not as clear as it could be; I hope my overhaul makes it clearer what I'm looking for. I moved from my crude hack to code based on the SVG standard's reference implemention which as the same expected behavior. – Alan De Smet Aug 09 '21 at 19:36
  • "those threads are zero or near zero opacity areas" A very promising clue, thank you! That is an _incredibly_ suspicious coincidence. I'll see if I can figure out why the alpha is impacting the result despite my attempts to discard it. – Alan De Smet Aug 09 '21 at 21:53
  • I think I have it! Per standard, filter operations work on premultiplied alpha RGB (0-255). It's done to because it fixes some blending problems. That loses information in the RGB channels, but it's okay because it's not apparent with the associated alpha. However, feColorMattrix and feComponentTransfer temporarily undo the premultiply to do their math, but again, it's okay as it's not apparent with the associated alpha... unless someone just hard sets alpha to 1.0... then the lost data becomes those lines. Solution: If you need 1 channel of turbulence, use A, not R, G, or B. – Alan De Smet Aug 09 '21 at 22:42
  • I can try to answer my own question more properly later (especially after I triple check my understanding), but if you'd like to write something up, I'd be happy to accept such an answer. Thank you for the help! – Alan De Smet Aug 09 '21 at 22:43
  • (This doesn't explain why fractalNoise doesn't have the same problem, but it may be apparent if I look at the algorithm more closely. But out of time for now.) – Alan De Smet Aug 09 '21 at 22:44
  • Hi Alan - no worries if you write your own answer - I'll even upvote it :) – Michael Mullany Aug 10 '21 at 10:58