Jump to content

Suggestion: improve quality of Hue / Saturation effect


Tanel

Recommended Posts

Hi,

while building and testing a new plugin, I discovered that Hue/Saturation effect in PdN renders somewhat jagged results, especially noticable on smooth gradients.

I know that this is caused by RGB>HSV>RGB conversion which is not 100% accurate.

I studied the PdN 3.22 source and came to conclusion that accuracy falls mostly during color values being thrown between several operations (UnaryPixelOps, RgbColor, HsvColor, ColorBgra). Current effect system passes color values between color operations by (int) or (byte). Therefore precision is rounded down repeatedly, resulting with coarse results.

I thought that this can be improved. "Bad" rounding can be avoided with passing color values by (float).

To test my theory, I collected the pieces of PdN 3.22 source and put together Codelab code to imitate the color operations. I replaced the (int) or (byte) conversions with (float) where possible. Where converting to (byte) was inevitable, I used round() function before conversion. (Sidenote: I haven't analysed the impact of each change separately, so some of those may be useless.)

Results are very good. Gradients are rendered smoothly even after running the effect several times.

You can download the codelab source with few comments in it (.cs file) and improved effect (.dll file) here:

-- attachment removed 2008-12-24 --

download the "Hue / Saturation+" as part of my plugin pack.

To test it and compare with original effect I recommend this test image with color, saturation and brightness gradients.

Here are some examples

hsv_sample_1.png

This is a piece of the linked testimage. See how gradients have got tiny steps in the middle pic. Even gray one which should not be affected by saturation adjustment. The right pic is still nicely smooth.

Now a "real life subject": piece of sky from a photo. Sky is always smooth gradient in terms of color.

hsv_sample_sky.png

I added contrast to exaggerate the result. See how middle pic became blotchy in some areas, while the right pic still holds up the gradient quite well.

One more issue which I *think* is wrong with original Hue/Saturation effect: order of processing.

Currently the adjustments order is Saturation>Hue>Lightness.

I think this is wrong because in case of extremely saturated colors (R, G or B channel at 255) the Saturation adjustment itself will change hue *before* Hue adjustment takes over. Some of you may think what's the difference, but there *is* difference because Saturation adjustment handles different colors differently. Correct adjustments order would be Hue>Saturation>Lightness, so that Saturation adjustment reads the intensity() value from Hue-adjusted color.

For example, take the above linked test image and open original Hue/Saturation effect. play with hue slider - color wheels "roll" smoothly. Now dial in saturation 150 and play with hue slider again. No more smooth rolling - color areas get stretched and compressed while you change hue.

Now try the same with "Hue/Saturation IMPROVED" effect where I have changed the order. See the difference?

I hope the improvements can be included to PdN somehow.

Please, your comments!

hue_saturation_improved.zip

Link to comment
Share on other sites

Nice improvement. A speed optimisation may also be done to this by using only integers with lookup tables (especialy for multiplications and divisions).

For exemple, when you need to multiply 2 numbers [0..1] but you have 2 numbers [0..255] you call the LUT which returns the result. Those LUTs are 65Ko long but it is faster to do this than using doubles (or floats).

The quality issue would just disappear because numbers are always scaled to [0..255] (and not [0..100] like the current algorithm).

I developped such an algorithm and it is 3.2 times faster (rendered as fast as PS' algorithm, nearly instant). However, I'm experiencing some problems :

- Increasing saturation slightly decrease light on some colors (and decreasing sat increase light on those colors)

- Increasing value only affects colors which are not affected by the saturation issue.

If I'm able to find the problem, I'll post my code, if someone wants to try to find the problem, just tell me, I'll post my code.

Link to comment
Share on other sites

Ok, I did it but there are some changes :

1. It doesn't use the HSV but the HSL algorithm.

2. The L component is not beautiful when modified (keeps the saturation, looks oversaturated). The solution is to leave the L component and then to apply the change directly on RGB when modifying Hue/Saturation/Lightness.

3. The quality is a little better than the actual algorithm but not that much.

4. I computed all possible values and compared to the good values, there is a maximum error of 1 when converting from RGB to HSL and 2 when converting from HSL to RGB. Average error vary between components from 0.05 to 0.5.

5. H component is scaled to 0..359, S and L are scaled to 0..255.

I didn't benchmark this version but it may be about 3 times faster than the actual algorithm.

Feel free to improve the code =)

PdnHSL.zip

Link to comment
Share on other sites

  • 2 weeks later...

After some research, I've been able to highlight such a "bad result" (but really better than PDN) in Photoshop. It means they probably developped extremly complicated algorithms based on integers.

As such an algorithm is certainly by far too complicated to me, I think the good choice for PDN would be to keep using floats but without any rounding. Hue, saturation and lightness must be floats and remain floats to get modified until they turn back to RGB. For exemple, the Hue of RGB (85, 120, 201) is 221.8965. If you keep this exact value, you will get the exact RGB when you go back to RGB. If you round, ceil, floor or just cast this value, you won't be sure to get the exact value.

So, the only thing you have to do is to keep HSL as floats and the result will be color perfect. I tried it with all possible values for RGB (RGB => HSL => RGB) and the result is always exactly the same.

Link to comment
Share on other sites

Ok... I don't know if this will help Paint.Net, maybe can help someone... I've been able to write an algorithm with only integers and with accurate result.

The trick is the scale of the values (H : 0..24480, S & L : 0..4080) wich gives enough precision to convert from RGB to HSL then back to RGB and to get the same RGB. It also uses a small lookup table to scale down RGB from 4080 to 255 with a round.

I tested the performances, it is 2.4 times faster than the floating-point algorithm.

Here it is !

OptimizedHSL.zip

Link to comment
Share on other sites

Can your plugin be enhanced so it affects only specific channels / colors - or does affect only one channel / color range? The point is, when I adjust saturation, often some colors (red) become too saturated and look ugly. This happens especially when working with drawings. Fine tuning would really be nice.

It's worth to make something advanced out of this (like shadow/highlight recovery) - I think that would be stunning.

Link to comment
Share on other sites

  • 3 weeks later...

For plugin developers being interested, here is the C# class for RGB to HSV transformations, operating with floats. It returns near perfect accuracy. I used it in my Color Mixer plugin.

(Somehow I couldn't get ennixo's solution work as intended)

RGBtoHSV.zipIf using it in your project, call the transformation as follows:

RGBtoHSV.GetHSV(R, G, B, out H, out S, out V);

or

RGBtoHSV.GetRGB(H, S, V, out R, out G, out B);

All RGB and HSV values to be (float).

Link to comment
Share on other sites

  • 4 months later...

I'm using some of the PdN code within my own application to do a few graphical adjustments on-the-fly, and ran into this issue. The adjusted gradients did not look very good after applying the hue/saturation/lightness effect.

This thread helped a lot. Using Tanel's suggestions and ennixo's code, I made the necessary modifications to the PdN 3.36 code to apply an accurate HSL adjustment.

Thanks ennixo and Tanel!

In UnaryPixelOps.cs, change the code for class HueSaturationLightness to:

       [serializable]
       public class HueSaturationLightness
           : UnaryPixelOp
       {
           private int hueDelta;
           private int satFactor;
           private UnaryPixelOp blendOp;

           public HueSaturationLightness(int hueDelta, int satDelta, int lightness)
           {
               //this.hueDelta = hueDelta;
               this.hueDelta = hueDelta * (HslHelper.MAX6 / 360);
               this.satFactor = (int)Math.Round((satDelta * 1024) / (double)100);

               if (lightness == 0)
               {
                   //blendOp = new UnaryPixelOps.Identity();
                   blendOp = null;
               }
               else if (lightness > 0)
               {
                   blendOp = new UnaryPixelOps.BlendConstant(ColorBgra.FromBgra(255, 255, 255, (byte)Math.Round((lightness * 255) / (double)100)));
               }
               else // if (lightness < 0)
               {
                   blendOp = new UnaryPixelOps.BlendConstant(ColorBgra.FromBgra(0, 0, 0, (byte)Math.Round((-lightness * 255) / (double)100)));
               }
           }

           public override ColorBgra Apply(ColorBgra color)
           {
               ColorBgra origColor = color;

               // adjust hue
               if (hueDelta != 0)
               {
                   //HsvColor hsvColor = HsvColor.FromColor(color.ToColor());
                   //int hue = hsvColor.Hue;
                   //
                   //hue += hueDelta;
                   //
                   //while (hue < 0)
                   //{
                   //    hue += 360;
                   //}
                   //
                   //while (hue > 360)
                   //{
                   //    hue -= 360;
                   //}
                   //
                   //hsvColor.Hue = hue;
                   //ColorBgra color = ColorBgra.FromColor(hsvColor.ToColor());

                   int h, s, l;
                   HslHelper.GetHSL(color.R, color.G, color.B, out h, out s, out l);
                   h += hueDelta;
                   while (h < 0)
                       h += HslHelper.MAX6;
                   while (h >= HslHelper.MAX6)
                       h -= HslHelper.MAX6;
                   HslHelper.GetRGB(h, s, l, out color.R, out color.G, out color.;
               }

               //adjust saturation
               if (satFactor != 1024)
               {
                   //byte intensity = color.GetIntensityByte();
                   double intensity = color.GetIntensity() * (double)255;
                   color.R = Utility.ClampToByte((int)Math.Round(intensity * (double)1024 + (color.R - intensity) * (double)satFactor) >> 10);
                   color.G = Utility.ClampToByte((int)Math.Round(intensity * (double)1024 + (color.G - intensity) * (double)satFactor) >> 10);
                   color.B = Utility.ClampToByte((int)Math.Round(intensity * (double)1024 + (color.B - intensity) * (double)satFactor) >> 10);
               }

               // adjust lightness
               if (blendOp != null)
                   color = blendOp.Apply(color);

               // set alpha
               color.A = origColor.A;

               return color;
           }
       }

Create a new file HslHelper.cs with this, slightly modified from ennixo's code:

using System;

namespace PaintDotNet
{
   /// 
   /// Lookup tables as a singleton
   /// 
   public class HslLookup
   {
       #region Tableaux

       /// 
       /// Scales a byte to a float [0..1]
       /// = (double)i / 255d;
       /// 
       public readonly double[] Scale255To1Float;

       /// 
       /// Scales a number [0..4080] to [0..255]
       /// = (byte)Math.Round((double)i / 16d);
       /// 
       public readonly byte[] Scale4080To255Byte;

       #endregion

       #region Initialisation

       /// 
       /// Initializes tables
       /// 
       private HslLookup()
       {
           Scale255To1Float = new double[256];
           Scale4080To255Byte = new byte[4081];

           for (int i = 0; i < 4081; ++i)
           {
               Scale4080To255Byte[i] = (byte)Math.Round((double)i / 16d);
           }


           for (int i = 0; i < 256; ++i)
           {
               Scale255To1Float[i] = (double)i / 255d;
           }

       }

       #endregion

       #region Static

       /// 
       /// Singleton of Lookup
       /// 
       public static readonly HslLookup Current;

       /// 
       /// Initializes the singleton
       /// 
       static HslLookup()
       {
           Current = new HslLookup();
       }

       #endregion
   }

   /// 
   /// HSL/RGB converter
   /// 
   public static class HslHelper
   {
       #region Constants

       public const int SCALESHIFT = 4;
       public const int MAX = 255 << SCALESHIFT;
       public const int HALF = MAX >> 1;
       private const int MAX2 = MAX << 1;
       private const int MAX4 = MAX << 2;
       public const int MAX6 = MAX2 + MAX4;
       private const int ONESIXTH = MAX / 6;
       private const int ONETHIRD = MAX / 3;
       private const int TWOTHIRD = MAX2 / 3;

       #endregion

       /// 
       /// Converts an RGB color to an HSL color with scaled numbers
       /// 
       /// Red 0..255
       /// Green 0..255
       /// Blue 0..255
       /// Hue 0..24480
       /// Saturation 0..4080
       /// Lightness 0..4080
       public static void GetHSL(byte rB, byte gB, byte bB, out int h, out int s, out int l)
       {
           int r = rB << SCALESHIFT;
           int g = gB << SCALESHIFT;
           int b = bB << SCALESHIFT;

           int min = r;
           if (min > g) min = g;
           if (min >  min = b;

           int max = r;
           if (max < g) max = g;
           if (max <  max = b;

           int delta = max - min;

           l = (max + min) >> 1;

           if (max == 0 || delta == 0)
           {
               s = 0;
               h = 0;
           }
           else
           {
               if (l == 0)
               {
                   s = MAX;
               }
               else if (l < HALF)
               {
                   s = ((delta * MAX) / l) >> 1;
               }
               else
               {
                   s = (((delta * MAX) / (MAX - l))) >> 1;
               }

               if (r == max)
               {
                   h = ((g -  * MAX) / delta;
               }
               else if (g == max)
               {
                   h = MAX2 + (((b - r) * MAX) / delta);
               }
               else
               {
                   h = MAX4 + (((r - g) * MAX) / delta);
               }

               if (h < 0)
               {
                   h += MAX6;
               }
           }
       }

       /// 
       /// Converts an HSL color with scaled numbers to an RGB color
       /// 
       /// Hue 0..24480
       /// Saturation 0..4080
       /// Lightness 0..4080
       /// Red 0..255
       /// Green 0..255
       /// Blue 0..255
       public static void GetRGB(int h, int s, int l, out byte rB, out byte gB, out byte bB)
       {
           HslLookup lut = HslLookup.Current;

           int q, p, hk, r, g, b;

           if (l < HALF)
           {
               q = (int)(((l * (MAX + s)) * 0x8081L) >> (23 + SCALESHIFT));
           }
           else
           {
               q = (l + s) - (int)((((l * s)) * 0x8081L) >> (23 + SCALESHIFT));
           }

           p = (l << 1) - q;

           hk = (h * 10923) >> 16;

           r = hk + ONETHIRD;
           g = hk;
           b = hk - ONETHIRD;

           if (r > MAX) r -= MAX;
           if (b < 0) b += MAX;

           if (r < ONESIXTH)
           {
               r = p + (int)((((q - p) * 6 * r) * 0x8081L) >> (23 + SCALESHIFT));
           }
           else if (r < HALF)
           {
               r = q;
           }
           else if (r < TWOTHIRD)
           {
               r = p + (int)((((q - p) * 6 * (TWOTHIRD - r)) * 0x8081L) >> (23 + SCALESHIFT));
           }
           else
           {
               r = p;
           }

           if (g < ONESIXTH)
           {
               g = p + (int)((((q - p) * 6 * g) * 0x8081L) >> (23 + SCALESHIFT));

           }
           else if (g < HALF)
           {
               g = q;
           }
           else if (g < TWOTHIRD)
           {
               g = p + (int)((((q - p) * 6 * (TWOTHIRD - g)) * 0x8081L) >> (23 + SCALESHIFT));
           }
           else
           {
               g = p;
           }

           if (b < ONESIXTH)
           {
               b = p + (int)((((q - p) * 6 *  * 0x8081L) >> (23 + SCALESHIFT));

           }
           else if (b < HALF)
           {
               b = q;
           }
           else if (b < TWOTHIRD)
           {
               b = p + (int)((((q - p) * 6 * (TWOTHIRD - ) * 0x8081L) >> (23 + SCALESHIFT));
           }
           else
           {
               b = p;
           }

           rB = lut.Scale4080To255Byte[r];
           gB = lut.Scale4080To255Byte[g];
           bB = lut.Scale4080To255Byte[b];
       }
   }
}

Link to comment
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

×
×
  • Create New...