How to convert a colored image to a image that has only two predefined colors?

前端 未结 1 2015
孤城傲影
孤城傲影 2020-12-12 04:11

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 Thresho

相关标签:
1条回答
  • 2020-12-12 04:50

    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;
    
    0 讨论(0)
提交回复
热议问题