Search code examples
c#xamarin.formsmvvmuwpskiasharp

Find pixel coordinates by color (color picker control)


I am currently trying to modify color picker control. Everything seems to be working as expected. However I would like to have possibility to set "selectedColor" on initialization. So that flow would be as follows:

  1. Pick needed color
  2. Save it in Preferences
  3. Close application
  4. Open application again
  5. Color picker is initialized on previously selected color

Currently picker is taking in account only coordinates of pointer X and Y. This means if I will provide previously selected color for Color picker control it will not be able to place pointer in a right place, because it is waiting for X, Y coordinates and not color. I have got a work-around where I save all needed parameters into string (Color Hex code, as well as X and Y coordinates). It is working, however this is adding additional complexity for combining strings and then parsing them inside ViewModels.

I have been getting familiar with possibility to read pixels, searching for needed color and getting it's coordinates. Here are some problems:

  1. Loop iteration for reading pixels is freezing UI, especially for larger color pickers (large image)
  2. Not always providing correct coordinates
  3. During initialization there is problem with black and white colors #00000000 and #FFFFFFFF. So I have added them into if method. It looks like before color picker is actually generated image is black and white? This is of course not a good solution in real case scenario as picked color can be white or black:
  • if (this.PickedColor.ToSKColor() == pixelColor 
      && this.PickedColor.ToSKColor() != Color.FromHex("#00000000").ToSKColor() 
      && this.PickedColor.ToSKColor() != Color.FromHex("#FFFFFFFF").ToSKColor())
    {
      //this.SelectedPoint = new Point(x, y);
      Debug.WriteLine(String.Format("Color: {0} | Coordinate: {1} {2}", pixelColor, x, y));
    }
    

Here is OnPaintSurface method of Color Picker control (you can see at the bottom method this.GetPixelCoordinates(bitmap); that is commented out):

protected override void OnPaintSurface(SKPaintSurfaceEventArgs e)
{
  SKImageInfo skImageInfo = e.Info;
  SKSurface skSurface = e.Surface;
  this.SKCanvas = skSurface.Canvas;

  int skCanvasWidth = skImageInfo.Width;
  int skCanvasHeight = skImageInfo.Height;

  this.SKCanvas.Clear();

  // Draw gradient rainbow Color spectrum
  using (SKPaint paint = new SKPaint())
  {
    paint.IsAntialias = true;

    System.Collections.Generic.List<SKColor> colors = new System.Collections.Generic.List<SKColor>();
    this.ColorList.ForEach((color) => { colors.Add(Color.FromHex(color).ToSKColor()); });

    // create the gradient shader between Colors
    using (SKShader shader = SKShader.CreateLinearGradient(
        new SKPoint(0, 0),
        this.ColorListDirection == ColorListDirection.Horizontal ?
            new SKPoint(skCanvasWidth, 0) : new SKPoint(0, skCanvasHeight),
        colors.ToArray(),
        null,
        SKShaderTileMode.Clamp))
    {
      paint.Shader = shader;
      this.SKCanvas.DrawPaint(paint);
    }
  }

  // Draw darker gradient spectrum
  using (SKPaint paint = new SKPaint())
  {
    paint.IsAntialias = true;

    // Initiate the darkened primary color list
    SKColor[] colors = GetGradientOrder();

    // create the gradient shader 
    using (SKShader shader = SKShader.CreateLinearGradient(
        new SKPoint(0, 0),
        this.ColorListDirection == ColorListDirection.Horizontal ?
            new SKPoint(0, skCanvasHeight) : new SKPoint(skCanvasWidth, 0),
        colors,
        null,
        SKShaderTileMode.Clamp))
    {
      paint.Shader = shader;
      this.SKCanvas.DrawPaint(paint);
    }
  }

  // Picking the Pixel Color values on the Touch Point

  // Represent the color of the current Touch point
  SKColor touchPointColor;

  // Efficient and fast
  // https://forums.xamarin.com/discussion/92899/read-a-pixel-info-from-a-canvas
  // create the 1x1 bitmap (auto allocates the pixel buffer)
  using (SKBitmap bitmap = new SKBitmap(skImageInfo))
  {
    // get the pixel buffer for the bitmap
    IntPtr dstpixels = bitmap.GetPixels();

    // read the surface into the bitmap
    skSurface.ReadPixels(skImageInfo,
        dstpixels,
        skImageInfo.RowBytes,
        (int)this.SelectedPoint.X,
        (int)this.SelectedPoint.Y);

    // access the color
    touchPointColor = bitmap.GetPixel(0, 0);

    //this.GetPixelCoordinates(bitmap);

    //bitmap.SetPixel(50, 50, this.PickedColor.ToSKColor());
  }

Here is GetPixelCoordinates method:

private void GetPixelCoordinates(SKBitmap bitmap)
{
  if (bitmap == null)
  {
    return;
  }

  for (int x = 0; x < bitmap.Width; x++)
  {
    for (int y = 0; y < bitmap.Height; y++)
    {
      SKColor pixelColor = bitmap.GetPixel(x, y);

      if (this.PickedColor.ToSKColor() == pixelColor 
        && this.PickedColor.ToSKColor() != Color.FromHex("#00000000").ToSKColor() 
        && this.PickedColor.ToSKColor() != Color.FromHex("#FFFFFFFF").ToSKColor())
      {
        //this.SelectedPoint = new Point(x, y);
        Debug.WriteLine(String.Format("Color: {0} | Coordinate: {1} {2}", pixelColor, x, y));
      }
    }
  }
}

Here is PickedColor property:

public static readonly BindableProperty PickedColorProperty
  = BindableProperty.Create(
    propertyName: nameof(PickedColor),
    returnType: typeof(Color),
    declaringType: typeof(ColorPicker),
    defaultValue: Color.Green,
    defaultBindingMode: BindingMode.TwoWay,
    propertyChanged: OnColorChanged);

private static void OnColorChanged(BindableObject bindable, object oldValue, object newValue)
{
  ColorPicker control = (ColorPicker)bindable;
  control.PickedColor = (Color)newValue;
}

/// <summary>
/// Set the Color Spectrum Gradient Style
/// </summary>
public GradientColorStyle GradientColorStyle
{
  get { return (GradientColorStyle)GetValue(GradientColorStyleProperty); }
  set { SetValue(GradientColorStyleProperty, value); }
}

public static readonly BindableProperty ColorListProperty
  = BindableProperty.Create(
        propertyName: nameof(ColorList),
        returnType: typeof(string[]),
        declaringType: typeof(ColorPicker),
        defaultValue: new string[]
        {
          new Color(255, 0, 0).ToHex(), // Red
                new Color(255, 255, 0).ToHex(), // Yellow
                new Color(0, 255, 0).ToHex(), // Green (Lime)
                new Color(0, 255, 255).ToHex(), // Aqua
                new Color(0, 0, 255).ToHex(), // Blue
                new Color(255, 0, 255).ToHex(), // Fuchsia
                new Color(255, 0, 0).ToHex(), // Red
        },
        defaultBindingMode: BindingMode.OneTime, null);

My question is: Is generating string with parameters the only way to go (Color Hex code, as well as X and Y coordinates)? Is there some possibility to place pointer on control initialization by provided color in some efficient way without constant loop iteration and freeze of UI?

Color palette:

enter image description here


Solution

  • StackOverflow is designed for each post to ask and answer one coding question.
    (To ask a second question, please make a new post. Include in that post the essential details needed to understand that question. Link back to this question, so you don't have to repeat the information that gives additional background/context.)

    Your primary question can be stated as:

    Given: A color palette [see picture] generated by [see OnPaintSurface code, starting at // Draw gradient rainbow Color spectrum. How calculate (x,y) coordinates that correspond to a given color?


    First, an observation. That 2D palette gives 2 of 3 color axes. You'll need a separate "saturation" slider, to allow picking of any color.

    The palette you show is an approximation to an "HSV" color model.
    In wiki HSL-HSV models, click on diagram at right. Your palette looks like the rectangle labeled S(HSV) = 1.

    Hue + Saturation + Value.
    Your ColorList should have fully Saturated colors at max Value.
    Going down the screen, the palette reduces Value to near zero.


    This is the beginning of an answer to that.

    What is needed, is a mathematical formula that corresponds to what is drawn.
    Lets look at how that rectangular image was generated.

    Rename the color lists so easier to work with. Store as fields, so can use them later. Use the original Colors from which the SkColors were generated, for easier manipulation.

        private List<Color> saturatedColors;
        private List<Color> darkenedColors;
        private int nColors => saturatedColors.Count;
        private int maxX, maxY;   // From your UI rectangle.
    

    The top row (y=0) has saturatedColors, evenly spaced across x.

    The bottom row (y=maxY) has darkenedColors, evenly spaced across x.

    The pixel colors are linearly interpolated from top row to bottom row.

    Goal is to find pixel closest to a given color, "Color goalColor".

    Consider each tall, thin rectangle whose corners are two topColors and the corresponding two bottomColors. Goal is to find which rectangle contains goalColor, then find the pixel within that rectangle that is closest to goalColor.

    The trickiest part is "comparing" colors, to decide when a color is "between" two colors. This is hard to do in RGB; convert colors to HSV to match the palette you are using. See Greg's answer - ColorToHSV.

    Its easier if you make an HSV class:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    // OR could use System.Drawing.Color.
    using Color = Xamarin.Forms.Color;
    ...
        public class HSV
        {
            #region --- static ---
            public static HSV FromColor(Color color)
            {
                ColorToHSV(color, out double hue, out double saturation, out double value);
                return new HSV(hue, saturation, value);
            }
    
            public static List<HSV> FromColors(IEnumerable<Color> colors)
            {
                return colors.Select(color => FromColor(color)).ToList();
            }
    
            const double Epsilon = 0.000001;
    
            // returns Tuple<int colorIndex, double wgtB>.
            public static Tuple<int, double> FindHueInColors(IList<HSV> colors, double goalHue)
            {
                int colorIndex;
                double wgtB = 0;
                // "- 1": because each iteration needs colors[colorIndex+1].
                for (colorIndex = 0; colorIndex < colors.Count - 1; colorIndex++)
                {
                    wgtB = colors[colorIndex].WgtFromHue(colors[colorIndex + 1], goalHue);
                    // Epsilon compensates for possible round-off error in WgtFromHue.
                    // To ensure the color is considered within one of the ranges.
                    if (wgtB >= 0 - Epsilon && wgtB < 1)
                        break;
                }
    
                return new Tuple<int, double>(colorIndex, wgtB);
            }
    
            // From https://stackoverflow.com/a/1626175/199364.
            public static void ColorToHSV(Color color, out double hue, out double saturation, out double value)
            {
                int max = Math.Max(color.R, Math.Max(color.G, color.B));
                int min = Math.Min(color.R, Math.Min(color.G, color.B));
    
                hue = color.GetHue();
                saturation = (max == 0) ? 0 : 1d - (1d * min / max);
                value = max / 255d;
            }
            // From https://stackoverflow.com/a/1626175/199364.
            public static Color ColorFromHSV(double hue, double saturation, double value)
            {
                int hi = Convert.ToInt32(Math.Floor(hue / 60)) % 6;
                double f = hue / 60 - Math.Floor(hue / 60);
    
                value = value * 255;
                int v = Convert.ToInt32(value);
                int p = Convert.ToInt32(value * (1 - saturation));
                int q = Convert.ToInt32(value * (1 - f * saturation));
                int t = Convert.ToInt32(value * (1 - (1 - f) * saturation));
    
                if (hi == 0)
                    return Color.FromArgb(255, v, t, p);
                else if (hi == 1)
                    return Color.FromArgb(255, q, v, p);
                else if (hi == 2)
                    return Color.FromArgb(255, p, v, t);
                else if (hi == 3)
                    return Color.FromArgb(255, p, q, v);
                else if (hi == 4)
                    return Color.FromArgb(255, t, p, v);
                else
                    return Color.FromArgb(255, v, p, q);
            }
            #endregion
    
    
            public double H { get; set; }
            public double S { get; set; }
            public double V { get; set; }
    
            // c'tors
            public HSV()
            {
            }
            public HSV(double h, double s, double v)
            {
                H = h;
                S = s;
                V = v;
            }
    
            public Color ToColor()
            {
                return ColorFromHSV(H, S, V);
            }
    
            public HSV Lerp(HSV b, double wgtB)
            {
                return new HSV(
                    MathExt.Lerp(H, b.H, wgtB),
                    MathExt.Lerp(S, b.S, wgtB),
                    MathExt.Lerp(V, b.V, wgtB));
            }
    
            // Returns "wgtB", such that goalHue = Lerp(H, b.H, wgtB).
            // If a and b have same S and V, then this is a measure of
            // how far to move along segment (a, b), to reach goalHue.
            public double WgtFromHue(HSV b, double goalHue)
            {
                return MathExt.Lerp(H, b.H, goalHue);
            }
            // Returns "wgtB", such that goalValue = Lerp(V, b.V, wgtB).
            public double WgtFromValue(HSV b, double goalValue)
            {
                return MathExt.Lerp(V, b.V, goalValue);
            }
        }
    
        public static class MathExt
        {
            public static double Lerp(double a, double b, double wgtB)
            {
                return a + (wgtB * (b - a));
            }
    
            // Converse of Lerp:
            // returns "wgtB", such that
            //   result == lerp(a, b, wgtB)
            public static double WgtFromResult(double a, double b, double result)
            {
                double denominator = b - a;
    
                if (Math.Abs(denominator) < 0.00000001)
                {
                    if (Math.Abs(result - a) < 0.00000001)
                        // Any value is "valid"; return the average.
                        return 0.5;
    
                    // Unsolvable - no weight can return this result.
                    return double.NaN;
                }
    
                double wgtB = (result - a) / denominator;
                return wgtB;
            }
        }
    

    Usage:

        public static class Tests {
            public static void TestFindHueInColors(List<Color> saturatedColors, Color goalColor)
            {
                List<HSV> hsvColors = HSV.FromColors(saturatedColors);
                HSV goalHSV = HSV.FromColor(goalColor);
                var hueAt = HSV.FindHueInColors(hsvColors, goalHSV.H);
                int colorIndex = hueAt.Item1;
                double wgtB = hueAt.Item2;
                // ...
            }
        }
    

    This is the essence of the approach. From colorIndex, nColors, wgtB, and maxX, it is possible to calculate x. I recommend writing several test cases, to figure out how to do so.

    Calculating y is much simpler. Should be possible using goalHSV.V and maxY.

    As you can see, this is not trivial to code.

    The most important points:

    • Convert to HSV color space.
    • The palette is composed of tall, thin rectangles. Each rectangle has two saturated and max-value colors at the top two corners: (H1, 1.0, 1.0) and (H2, 1.0, 1.0). The bottom two corners are same hues and saturation, but small value. Perhaps (H1, 1.0, 0.01) and (H2, 1.0, 0.01). Convert your actual darkened values to HSV, to see the exact values.
    • Find which H's goalHSV is between.
    • Learn about "Linear Interpolation" ("Lerp"). In that rectangle, the top edge is a Lerp between the two saturated colors, the side edges is a Lerp from a bright color to the corresponding darkened color.

    If the above math is too intense, then draw a box with just one of those rectangles. That is, make the gradient with only TWO colors in the top list. Experiment with trying to locate a color pixel within that rectangle.

    IMPORTANT: There might not be a pixel that is EXACTLY the color you are starting with. Find which pixel is closest to that color.
    If you aren't sure you have the "best" pixel, then read a few nearby pixels, decide which is "closest". That is, which has the smallest var error = (r2-r1)*(r2-r1) + (g2-g1)*(g2-g1) + (b2-b1)*(b2-b1);.