1

I am trying to convert a colored image to a image that only has two colors. My approach was first converting the image to a black and white image by using Aforge.Net Threshold class and then convert the black and white pixels into colors that I want. The display is on real-time so this approach introduces a significant delay. I was wondering if there's a more straightforward way of doing this.

Bitmap image = (Bitmap)eventArgs.Frame.Clone();
Grayscale greyscale = new Grayscale(0.2125, 0.7154, 0.0721);
Bitmap grayImage = greyscale.Apply(image);
Threshold threshold = new Threshold(trigger);
threshold.ApplyInPlace(grayImage);
Bitmap colorImage = CreateNonIndexedImage(grayImage);
if (colorFilter)
{
    for (int y = 0; y < colorImage.Height; y++)
    {
        for (int x = 0; x < colorImage.Width; x++)
        {

            if (colorImage.GetPixel(x, y).R == 0 && colorImage.GetPixel(x, y).G == 0 && colorImage.GetPixel(x, y).B == 0)
            {
                colorImage.SetPixel(x, y, Color.Blue);
            }
            else
            {
                colorImage.SetPixel(x, y, Color.Yellow);
            }
        }
    }
}

private Bitmap CreateNonIndexedImage(Image src)
{
    Bitmap newBmp = new Bitmap(src.Width, src.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);

    using (Graphics gfx = Graphics.FromImage(newBmp))
    {
        gfx.DrawImage(src, 0, 0);
    }

    return newBmp;
}
Barte
  • 306
  • 1
  • 5
  • 16
  • Have you timed the individual steps, to identify what stage of your processing is slow? My guess is that your for loop is the performance hog, but that's just a guess. Also, why not just define a color map, and use the Image/Bitmap constructors for the color map, instead of doing the color map definition yourself? – John Aug 02 '17 at 18:55
  • 3
    `GetPixel` and `SetPixel` are extremely slow, just don't use them. – harold Aug 02 '17 at 18:56
  • What exactly do you want to do though? Do you actually want recoloured black/white conversion, or do you want to match the image as closely as possible to the colours yellow and blue? Because those actions are not identical at all. For closest colour match, the general method is to calculate the Pythagorean distance between the colours in a 3D environment with R,G and B as axes. – Nyerguds Sep 05 '17 at 13:17
  • btw, does the `CreateNonIndexedImage` mean your original black/white converted image is indexed? Because then you can simply change the indexed image's palette colours to yellow and blue and you're done. Though from what I can see that looks like it'd create a 256-tint grayscale image, not a pure black/white one. – Nyerguds Sep 05 '17 at 13:21
  • @Nyerguds Yes, the original image is indexed. Would it be possible for you to give me some pointers as to how to change the palette colors of the indexed image? – Barte Sep 07 '17 at 16:34
  • @Barte Did you try my answer? If it solved your issue, please [accept it as answer](https://stackoverflow.com/help/someone-answers). – Nyerguds Mar 01 '18 at 22:32
  • @Nyerguds Yes I did try your code. In my particular case it did not meet the expected speed rate. However, I accepted your answer since it considerably increased the speedup. – Barte Mar 02 '18 at 11:09
  • Well, as I said, it can be made even faster by executing all code inside the `LockBits` part, using bare pointers to manipulate the bytes of both the source and target images directly instead of copying them out with `Marshal.Copy`. But you'll have to optimise that yourself; I linked to the relevant information. – Nyerguds Mar 02 '18 at 11:46
  • I edited the answer and added a _very_ quick way to do this on purely paletted images. – Nyerguds Mar 02 '18 at 14:09

1 Answers1

6

The normal way to match an image to specific colours is to use Pythagorean distance between the colours in a 3D environment with R, G and B as axes. I got a bunch of toolsets for manipulating images and colours, and I'm not too familiar with any external frameworks, so I'll just dig through my stuff and give you the relevant functions.

First of all, the colour replacement itself. This code will match any colour you give to the closest available colour on a limited palette, and return the index in the given array. Note that I left out the "take the square root" part of the Pythagorean distance calculation; we don't need to know the actual distance, we only need to compare them, and that works just as well without that rather CPU-heavy operation.

public static Int32 GetClosestPaletteIndexMatch(Color col, Color[] colorPalette)
{
    Int32 colorMatch = 0;
    Int32 leastDistance = Int32.MaxValue;
    Int32 red = col.R;
    Int32 green = col.G;
    Int32 blue = col.B;
    for (Int32 i = 0; i < colorPalette.Length; i++)
    {
        Color paletteColor = colorPalette[i];
        Int32 redDistance = paletteColor.R - red;
        Int32 greenDistance = paletteColor.G - green;
        Int32 blueDistance = paletteColor.B - blue;
        Int32 distance = (redDistance * redDistance) + (greenDistance * greenDistance) + (blueDistance * blueDistance);
        if (distance >= leastDistance)
            continue;
        colorMatch = i;
        leastDistance = distance;
        if (distance == 0)
            return i;
    }
    return colorMatch;
}

Now, on a high-coloured image, this palette matching would have to be done for every pixel on the image, but if your input is guaranteed to be paletted already, then you can just do it on the colour palette, reducing your palette lookups to just 256 per image:

Color[] colors = new Color[] {Color.Black, Color.White };
ColorPalette pal = image.Palette;
for(Int32 i = 0; i < pal.Entries.Length; i++)
{
    Int32 foundIndex = ColorUtils.GetClosestPaletteIndexMatch(pal.Entries[i], colors);
    pal.Entries[i] = colors[foundIndex];
}
image.Palette = pal;

And that's it; all colours on the palette replaced by their closest match.

Note that the Palette property actually makes a new ColorPalette object, and doesn't reference the one in the image, so the code image.Palette.Entries[0] = Color.Blue; would not work, since it'd just modify that unreferenced copy. Because of that, the palette object always has to be taken out, edited and then reassigned to the image.

If you need to save the result to the same filename, there's a trick with a stream you can use, but if you simply need the object to have its palette changed to these two colours, that's really it.


In case you are not sure of the original image format, the process is quite a bit more involved:

As mentioned before in the comments, GetPixel and SetPixel are extremely slow, and it's much more efficient to access the image's underlying bytes. However, unless you are 100% certain what your input type's pixel format is, you can't just go and access these bytes, since you need to know how to read them. A simple workaround for this is to just let the framework do the work for you, by painting your existing image on a new 32 bits per pixel image:

public static Bitmap PaintOn32bpp(Image image, Color? transparencyFillColor)
{
    Bitmap bp = new Bitmap(image.Width, image.Height, PixelFormat.Format32bppArgb);
    using (Graphics gr = Graphics.FromImage(bp))
    {
        if (transparencyFillColor.HasValue)
            using (System.Drawing.SolidBrush myBrush = new System.Drawing.SolidBrush(Color.FromArgb(255, transparencyFillColor.Value)))
                gr.FillRectangle(myBrush, new Rectangle(0, 0, image.Width, image.Height));
        gr.DrawImage(image, new Rectangle(0, 0, bp.Width, bp.Height));
    }
    return bp;
}

Now, you probably want to make sure transparent pixels don't end up as whatever colour happens to be hiding behind an alpha value of 0, so you better specify the transparencyFillColor in this function to give a backdrop to remove any transparency from the source image.

Now we got the high-colour image, the next step is going over the image bytes, converting them to ARGB colours, and matching those to the palette, using the function I gave before. I'd advise making an 8-bit image because they're the easiest to edit as bytes, and the fact they have a colour palette makes it ridiculously easy to replace colours on them after they're created.

Anyway, the bytes. It's probably more efficient for large files to iterate through the bytes in unsafe memory right away, but I generally prefer copying them out. Your choice, of course; if you think it's worth it, you can combine the two functions below to access it directly. Here's a good example for accessing the colour bytes directly.

/// <summary>
/// Gets the raw bytes from an image.
/// </summary>
/// <param name="sourceImage">The image to get the bytes from.</param>
/// <param name="stride">Stride of the retrieved image data.</param>
/// <returns>The raw bytes of the image</returns>
public static Byte[] GetImageData(Bitmap sourceImage, out Int32 stride)
{
    BitmapData sourceData = sourceImage.LockBits(new Rectangle(0, 0, sourceImage.Width, sourceImage.Height), ImageLockMode.ReadOnly, sourceImage.PixelFormat);
    stride = sourceData.Stride;
    Byte[] data = new Byte[stride * sourceImage.Height];
    Marshal.Copy(sourceData.Scan0, data, 0, data.Length);
    sourceImage.UnlockBits(sourceData);
    return data;
}

Now, all you need to do is make an array to represent your 8-bit image, iterate over all bytes per four, and match the colours you get to the ones in your palette. Note that you can never assume that the actual byte length of one line of pixels (the stride) equals the width multiplied by the bytes per pixel. Because of this, while the code does simply add the pixel size to the read offset to get the next pixel on one line, it uses the stride for skipping over whole lines of pixels in the data.

public static Byte[] Convert32BitTo8Bit(Byte[] imageData, Int32 width, Int32 height, Color[] palette, ref Int32 stride)
{
    if (stride < width * 4)
        throw new ArgumentException("Stride is smaller than one pixel line!", "stride");
    Byte[] newImageData = new Byte[width * height];
    for (Int32 y = 0; y < height; y++)
    {
        Int32 inputOffs = y * stride;
        Int32 outputOffs = y * width;
        for (Int32 x = 0; x < width; x++)
        {
            // 32bppArgb: Order of the bytes is Alpha, Red, Green, Blue, but
            // since this is actually in the full 4-byte value read from the offset,
            // and this value is considered little-endian, they are actually in the
            // order BGRA. Since we're converting to a palette we ignore the alpha
            // one and just give RGB.
            Color c = Color.FromArgb(imageData[inputOffs + 2], imageData[inputOffs + 1], imageData[inputOffs]);
            // Match to palette index
            newImageData[outputOffs] = (Byte)ColorUtils.GetClosestPaletteIndexMatch(c, palette);
            inputOffs += 4;
            outputOffs++;
        }
    }
    stride = width;
    return newImageData;
}

Now we got our 8-bit array. To convert that array to an image you can use the BuildImage function I already posted on another answer.

So finally, using these tools, the conversion code should be something like this:

public static Bitmap ConvertToColors(Bitmap image, Color[] colors)
{
    Int32 width = image.Width;
    Int32 height = image.Height;
    Int32 stride;
    Byte[] hiColData;
    // use "using" to properly dispose of temporary image object.
    using (Bitmap hiColImage = PaintOn32bpp(image, colors[0]))
        hiColData = GetImageData(hiColImage, out stride);
    Byte[] eightBitData = Convert32BitTo8Bit(hiColData, width, height, colors, ref stride);
    return BuildImage(eightBitData, width, height, stride, PixelFormat.Format8bppIndexed, colors, Color.Black);
}

There we go; your image is converted to 8-bit paletted image, for whatever palette you want.

If you want to actually match to black and white and then replace the colours, that's no problem either; just do the conversion with a palette containing only black and white, then take the resulting bitmap's Palette object, replace the colours in it, and assign it back to the image.

Color[] colors = new Color[] {Color.Black, Color.White };
Bitmap newImage = ConvertToColors(image, colors);
ColorPalette pal = newImage.Palette;
pal.Entries[0] = Color.Blue;
pal.Entries[1] = Color.Yellow;
newImage.Palette = pal;
Nyerguds
  • 5,360
  • 1
  • 31
  • 63
  • 1
    Small addition to this... I have since found out that LockBits can _convert_ its output data to a certain pixel format. So if you just give `PixelFormat.Format32BppArgb` instead of `sourceImage.PixelFormat`, the whole `PaintOn32bpp` step is unnecessary, except maybe to get rid of transparency. – Nyerguds Oct 17 '19 at 13:37