2

Before nothing, I'll note that I'll accept C# or VB.NET solution.

I have this old code which I'm trying to refactor to avoid the bad habits and performance inefficiency of using GetPixel/SetPixel methods:

<Extension>
Public Function ChangeColor(ByVal sender As Image, 
                            ByVal oldColor As Color, 
                            ByVal newColor As Color) As Image

    Dim bmp As New Bitmap(sender.Width, sender.Height, sender.PixelFormat)

    Dim x As Integer = 0
    Dim y As Integer = 0

    While (x < bmp.Width)

        y = 0
        While y < bmp.Height
            If DirectCast(sender, Bitmap).GetPixel(x, y) = oldColor Then
                bmp.SetPixel(x, y, newColor)
            End If
            Math.Max(Threading.Interlocked.Increment(y), y - 1)
        End While
        Math.Max(Threading.Interlocked.Increment(x), x - 1)

    End While

    Return bmp

End Function

So, after reading the most voted solution here using LockBits approach, I'm trying to adapt the code to my needs, to use a Color as parameter instead of a sequence of bytes (because in essence they are the same):

<Extension>
Public Function ChangeColor(ByVal sender As Image, 
                            ByVal oldColor As Color, 
                            ByVal newColor As Color) As Image

   Dim bmp As Bitmap = DirectCast(sender.Clone, Bitmap)

   ' Lock the bitmap's bits.
   Dim rect As New Rectangle(0, 0, bmp.Width, bmp.Height)
   Dim bmpData As BitmapData = bmp.LockBits(rect, ImageLockMode.ReadWrite, bmp.PixelFormat)

    ' Get the address of the first line.
    Dim ptr As IntPtr = bmpData.Scan0

    ' Declare an array to hold the bytes of the bitmap. 
    Dim numBytes As Integer = (bmpData.Stride * bmp.Height)
    Dim rgbValues As Byte() = New Byte(numBytes - 1) {}

    ' Copy the RGB values into the array.
    Marshal.Copy(ptr, rgbValues, 0, numBytes)

    ' Manipulate the bitmap.
    For i As Integer = 0 To rgbValues.Length - 1 Step 3

      If (Color.FromArgb(rgbValues(i), rgbValues(i + 1), rgbValues(i + 2)) = oldColor) Then
          rgbValues(i) = newColor.R
          rgbValues(i + 1) = newColor.G
          rgbValues(i + 2) = newColor.B
      End If

    Next i

    ' Copy the RGB values back to the bitmap.
    Marshal.Copy(rgbValues, 0, ptr, numBytes)

    ' Unlock the bits.
    bmp.UnlockBits(bmpData)

    Return bmp

End Function

I have two problems with the extension method: the first is that if pixelformat is not Format24bppRgb as the original example then all goes wrong: an "IndexOutOfRange" exception is thrown in the loop. I suppose this is because I'm reading 3 bytes (RGB) instead of 4 (ARGB), but I'm not sure how to adapt it for any source pixelformat that I can pass to the function.

The second is that if I use Format24bppRgb as per the original C# example, the color changes to black.

Note that I'm not sure if the original solution given in the C# question that I linked to is wrong, because as per their comments it seems it is wrong in some way.

This is the way I'm trying to use it:

    ' This function creates a bitmap of a solid color.
    Dim srcImg As Bitmap = ImageUtil.CreateSolidcolorBitmap(New Size(256, 256), Color.Red)
    Dim modImg As Image = srcImg.ChangeColor(Color.Red, Color.Blue)

    PictureBox1.BackgroundImage = srcImg 
    PictureBox2.BackgroundImage = modImg 
Andrew Morton
  • 24,203
  • 9
  • 60
  • 84
ElektroStudios
  • 19,105
  • 33
  • 200
  • 417

2 Answers2

3

I suppose this is because I'm reading 3 bytes (RGB) instead of 4 (ARGB)

Yes, that's the point. If you want to manipulate the raw image content, you must rely on the PixelFormat. And you must differentiate the indexed formats (8bpp or less) where pixels in the BitmapData are not colors but indices of a color palette.

public void ChangeColor(Bitmap bitmap, Color from, Color to)
{
    if (Image.GetPixelFormatSize(bitmap.PixelFormat) > 8)
    {
        ChangeColorHiColoredBitmap(bitmap, from, to);
        return;
    }

    int indexFrom = Array.IndexOf(bitmap.Palette.Entries, from);
    if (indexFrom < 0)
        return; // nothing to change

    // we could replace the color in the palette but we want to see an example for manipulating the pixels
    int indexTo = Array.IndexOf(bitmap.Palette.Entries, to);
    if (indexTo < 0)
        return; // destination color not found - you can search for the nearest color if you want

    ChangeColorIndexedBitmap(bitmap, indexFrom, indexTo);
}

private unsafe void ChangeColorHiColoredBitmap(Bitmap bitmap, Color from, Color to)
{
    int rawFrom = from.ToArgb();
    int rawTo = to.ToArgb();

    BitmapData data = bitmap.LockBits(new Rectangle(Point.Empty, bitmap.Size), ImageLockMode.ReadWrite, bitmap.PixelFormat);
    byte* line = (byte*)data.Scan0;
    for (int y = 0; y < data.Height; y++)
    {
        for (int x = 0; x < data.Width; x++)
        {
            switch (data.PixelFormat)
            {
                case PixelFormat.Format24bppRgb:
                    byte* pos = line + x * 3;
                    int c24 = Color.FromArgb(pos[0], pos[1], pos[2]).ToArgb();
                    if (c24 == rawFrom)
                    {
                        pos[0] = (byte)(rawTo & 0xFF);
                        pos[1] = (byte)((rawTo >> 8) & 0xFF);
                        pos[2] = (byte)((rawTo >> 16) & 0xFF);
                    }
                    break;
                case PixelFormat.Format32bppRgb:
                case PixelFormat.Format32bppArgb:
                    int c32 = *((int*)line + x);
                    if (c32 == rawFrom)
                        *((int*)line + x) = rawTo;
                    break;
                default:
                    throw new NotSupportedException(); // of course, you can do the same for other pixelformats, too
            }
        }

        line += data.Stride;
    }

    bitmap.UnlockBits(data);
}

private unsafe void ChangeColorIndexedBitmap(Bitmap bitmap, int from, int to)
{
    int bpp = Image.GetPixelFormatSize(bitmap.PixelFormat);
    if (from < 0 || to < 0 || from >= (1 << bpp) || to >= (1 << bpp))
        throw new ArgumentOutOfRangeException();

    if (from == to)
        return;

    BitmapData data = bitmap.LockBits(
        new Rectangle(Point.Empty, bitmap.Size),
        ImageLockMode.ReadWrite,
        bitmap.PixelFormat);

    byte* line = (byte*)data.Scan0;

    // scanning through the lines
    for (int y = 0; y < data.Height; y++)
    {
        // scanning through the pixels within the line
        for (int x = 0; x < data.Width; x++)
        {
            switch (bpp)
            {
                case 8:
                    if (line[x] == from)
                        line[x] = (byte)to;
                    break;
                case 4:
                    // First pixel is the high nibble. From and To indices are 0..16
                    byte nibbles = line[x / 2];
                    if ((x & 1) == 0 ? nibbles >> 4 == from : (nibbles & 0x0F) == from)
                    {
                        if ((x & 1) == 0)
                        {
                            nibbles &= 0x0F;
                            nibbles |= (byte)(to << 4);
                        }
                        else
                        {
                            nibbles &= 0xF0;
                            nibbles |= (byte)to;
                        }

                        line[x / 2] = nibbles;
                    }
                    break;
                case 1:
                    // First pixel is MSB. From and To are 0 or 1.
                    int pos = x / 8;
                    byte mask = (byte)(128 >> (x & 7));
                    if (to == 0)
                        line[pos] &= (byte)~mask;
                    else
                        line[pos] |= mask;
                    break;
            }
        }

        line += data.Stride;
    }

    bitmap.UnlockBits(data);
}
György Kőszeg
  • 17,093
  • 6
  • 37
  • 65
  • So I have added examples for the 1, 4, 8, 24, and 32 bpp pixel formats. Of course, you can calculate the colors for 15/16/64 and other formats, too. I used unsafe methods here, but you can use `Marshal.Copy` to copy the data there and back by using a managed array. – György Kőszeg Nov 06 '15 at 09:48
2

There are three different problems in the code you posted:

  1. You have the color component order wrong. The Bitmap class stores pixel values as integers, in little-endian format. This means that the byte order of the components is actually BGR (or BGRA for 32bpp).
  2. In VB.NET, you can't directly compare Color values. I don't know enough about VB.NET to know why that is, but I assume it's a normal language behavior related to how VB.NET treats value types. To correctly compare Color values, you need to call ToArgb(), which returns an Integer value, which can be compared directly.
  3. Your For loop uses the wrong ending value. If you only subtract 1 from the length of the array, then it is possible for the loop to run into the padding at the end of a row, but find too few bytes to successfully add 2 to the loop index and still remain within the array.

Here's a version of your extension method that works fine for me:

<Extension>
Public Function ChangeColor(ByVal image As Image, ByVal oldColor As Color, ByVal newColor As Color)
    Dim newImage As Bitmap = New Bitmap(image.Width, image.Height, image.PixelFormat)

    Using g As Graphics = Graphics.FromImage(newImage)
        g.DrawImage(image, Point.Empty)
    End Using

    ' Lock the bitmap's bits.
    Dim rect As New Rectangle(0, 0, newImage.Width, newImage.Height)
    Dim bmpData As BitmapData = newImage.LockBits(rect, ImageLockMode.ReadWrite, newImage.PixelFormat)

    ' Get the address of the first line.
    Dim ptr As IntPtr = bmpData.Scan0

    ' Declare an array to hold the bytes of the bitmap. 
    Dim numBytes As Integer = (bmpData.Stride * newImage.Height)
    Dim rgbValues As Byte() = New Byte(numBytes - 1) {}

    ' Copy the RGB values into the array.
    Marshal.Copy(ptr, rgbValues, 0, numBytes)

    ' Manipulate the bitmap.
    For i As Integer = 0 To rgbValues.Length - 3 Step 3

        Dim testColor As Color = Color.FromArgb(rgbValues(i + 2), rgbValues(i + 1), rgbValues(i))

        If (testColor.ToArgb() = oldColor.ToArgb()) Then
            rgbValues(i) = newColor.B
            rgbValues(i + 1) = newColor.G
            rgbValues(i + 2) = newColor.R
        End If

    Next i

    ' Copy the RGB values back to the bitmap.
    Marshal.Copy(rgbValues, 0, ptr, numBytes)

    ' Unlock the bits.
    newImage.UnlockBits(bmpData)

    Return newImage

End Function

As far as this goes:

I'm not sure how to adapt it for any source pixelformat that I can pass to the function.

Unfortunately, the API does not directly return the bits-per-pixel or bytes-per-pixel for the bitmap. You can generalize your code to take into account the number of bytes per pixel, but you'll still have to at least map the PixelFormat value to that bytes per pixel value.

Peter Duniho
  • 68,759
  • 7
  • 102
  • 136
  • 1
    Thankyou for answer! In that case I will evaluate some pixelformats then throw a NotImplemented exception for unknown pixelformat. Could you please mention, or provide an url (from wikipedia? MSDN or other source) where to know each bytes-per-pixel of common pixelformats? by the moment I have: `PixelFormat.Format24bppRgb = 3, PixelFormat.Format32bppArgb = 4,PixelFormat.Format32bppRgb = 4` – ElektroStudios Nov 06 '15 at 09:28
  • Seems that in WPF we can get the bits-per-pixel: https://msdn.microsoft.com/en-us/library/system.windows.media.pixelformat.bitsperpixel%28v=vs.110%29.aspx – ElektroStudios Nov 06 '15 at 09:42