C# - Loading an indexed color image file correctly

So I created an indexed color, 8 bits-per-pixel PNG (I already checked with ImageMagick if the format is correct) and I want to load it from disk into a System.Drawing.Bitmap while keeping the 8bpp pixel format, in order to view (and manipulate) its palette. However, if I create a Bitmap like this:

Bitmap bitmap = new Bitmap("indexed-image.png");

The resulting Bitmap gets automatically converted to a 32bpp image format, and the bitmap.Palette.Entries field comes out as empty.

An answer to the question "How to convert a 32bpp image to 8bpp in C#?" here on StackOverflow said that this could be a valid way to convert it back to 8bpp:

bitmap = bitmap.Clone(new Rectangle(0, 0, bitmap.Width, bitmap.Height), PixelFormat.Format8bppIndexed);

This, however, produces incorrect results, as some colors in the palette are just plain wrong.

How can I load an image natively to 8bpp, or at least correctly convert a 32bpp one to 8bpp?


I had this problem too, and it seems that any paletted png image that contains transparency can't be loaded as being paletted by the .Net framework, despite the fact the .Net functions can perfectly write such a file. In contrast, it has no problems with this if the file is in gif format, or if the paletted png has no transparency.

Transparency in paletted png works by adding an optional "tRNS" chunk in the header, to specify the alpha of each palette entry. The .Net classes read and apply this correctly, so I don't really understand why then they insist on converting the image to 32 bit afterwards.

The structure of the png format is fairly simple; after the identifying bytes, each chunk is 4 bytes of the content size (big-endian), then 4 ASCII characters for the chunk id, then the chunk content itself, and finally a 4-byte chunk CRC value (again, saved as big-endian).

Given this structure, the solution is fairly simple:

  • Read the file into a byte array.
  • Ensure it is a paletted png file by analysing the header.
  • Find the "tRNS" chunk by jumping from chunk header to chunk header.
  • Read the alpha values from the chunk.
  • Make a new byte array containing the image data, but with the "tRNS" chunk cut out.
  • Create the Bitmap object using a MemoryStream created from the adjusted byte data, resulting in the correct 8-bit image.
  • Fix the color palette using the extracted alpha data.

If you do the checks and fallbacks right you can just load any image with this function, and if it happens to identify as paletted png with transparency info it'll perform the fix.

/// <summary>
/// Image loading toolset class which corrects the bug that prevents paletted PNG images with transparency from being loaded as paletted.
/// </summary>
public class BitmapHandler
{

    private static Byte[] PNG_IDENTIFIER = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};

    /// <summary>
    /// Loads an image, checks if it is a PNG containing palette transparency, and if so, ensures it loads correctly.
    /// The theory on the png internals can be found at http://www.libpng.org/pub/png/book/chapter08.html
    /// </summary>
    /// <param name="data">File data to load.</param>
    /// <returns>The loaded image.</returns>
    public static Bitmap LoadBitmap(Byte[] data)
    {
        Byte[] transparencyData = null;
        if (data.Length > PNG_IDENTIFIER.Length)
        {
            // Check if the image is a PNG.
            Byte[] compareData = new Byte[PNG_IDENTIFIER.Length];
            Array.Copy(data, compareData, PNG_IDENTIFIER.Length);
            if (PNG_IDENTIFIER.SequenceEqual(compareData))
            {
                // Check if it contains a palette.
                // I'm sure it can be looked up in the header somehow, but meh.
                Int32 plteOffset = FindChunk(data, "PLTE");
                if (plteOffset != -1)
                {
                    // Check if it contains a palette transparency chunk.
                    Int32 trnsOffset = FindChunk(data, "tRNS");
                    if (trnsOffset != -1)
                    {
                        // Get chunk
                        Int32 trnsLength = GetChunkDataLength(data, trnsOffset);
                        transparencyData = new Byte[trnsLength];
                        Array.Copy(data, trnsOffset + 8, transparencyData, 0, trnsLength);
                        // filter out the palette alpha chunk, make new data array
                        Byte[] data2 = new Byte[data.Length - (trnsLength + 12)];
                        Array.Copy(data, 0, data2, 0, trnsOffset);
                        Int32 trnsEnd = trnsOffset + trnsLength + 12;
                        Array.Copy(data, trnsEnd, data2, trnsOffset, data.Length - trnsEnd);
                        data = data2;
                    }
                }
            }
        }
        using(MemoryStream ms = new MemoryStream(data))
        using(Bitmap loadedImage = new Bitmap(ms))
        {
            if (loadedImage.Palette.Entries.Length != 0 && transparencyData != null)
            {
                ColorPalette pal = loadedImage.Palette;
                for (int i = 0; i < pal.Entries.Length; i++)
                {
                    if (i >= transparencyData.Length)
                        break;
                    Color col = pal.Entries[i];
                    pal.Entries[i] = Color.FromArgb(transparencyData[i], col.R, col.G, col.B);
                }
                loadedImage.Palette = pal;
            }
            // Images in .Net often cause odd crashes when their backing resource disappears.
            // This prevents that from happening by copying its inner contents into a new Bitmap object.
            return CloneImage(loadedImage, null);
        }
    }

    /// <summary>
    /// Finds the start of a png chunk. This assumes the image is already identified as PNG.
    /// It does not go over the first 8 bytes, but starts at the start of the header chunk.
    /// </summary>
    /// <param name="data">The bytes of the png image.</param>
    /// <param name="chunkName">The name of the chunk to find.</param>
    /// <returns>The index of the start of the png chunk, or -1 if the chunk was not found.</returns>
    private static Int32 FindChunk(Byte[] data, String chunkName)
    {
        if (data == null)
            throw new ArgumentNullException("data", "No data given!");
        if (chunkName == null)
            throw new ArgumentNullException("chunkName", "No chunk name given!");
        // Using UTF-8 as extra check to make sure the name does not contain > 127 values.
        Byte[] chunkNamebytes = Encoding.UTF8.GetBytes(chunkName);
        if (chunkName.Length != 4 || chunkNamebytes.Length != 4)
            throw new ArgumentException("Chunk name must be 4 ASCII characters!", "chunkName");
        Int32 offset = PNG_IDENTIFIER.Length;
        Int32 end = data.Length;
        Byte[] testBytes = new Byte[4];
        // continue until either the end is reached, or there is not enough space behind it for reading a new chunk
        while (offset + 12 < end)
        {
            Array.Copy(data, offset + 4, testBytes, 0, 4);
            if (chunkNamebytes.SequenceEqual(testBytes))
                return offset;
            Int32 chunkLength = GetChunkDataLength(data, offset);
            // chunk size + chunk header + chunk checksum = 12 bytes.
            offset += 12 + chunkLength;
        }
        return -1;
    }

    private static Int32 GetChunkDataLength(Byte[] data, Int32 offset)
    {
        if (offset + 4 > data.Length)
            throw new IndexOutOfRangeException("Bad chunk size in png image.");
        // Don't want to use BitConverter; then you have to check platform endianness and all that mess.
        Int32 length = data[offset + 3] + (data[offset + 2] << 8) + (data[offset + 1] << 16) + (data[offset] << 24);
        if (length < 0)
            throw new IndexOutOfRangeException("Bad chunk size in png image.");
        return length;
    }

    /// <summary>
    /// Clones an image object to free it from any backing resources.
    /// Code taken from http://stackoverflow.com/a/3661892/ with some extra fixes.
    /// </summary>
    /// <param name="sourceImage">The image to clone.</param>
    /// <returns>The cloned image.</returns>
    public static Bitmap CloneImage(Bitmap sourceImage)
    {
        Rectangle rect = new Rectangle(0, 0, sourceImage.Width, sourceImage.Height);
        Bitmap targetImage = new Bitmap(rect.Width, rect.Height, sourceImage.PixelFormat);
        targetImage.SetResolution(sourceImage.HorizontalResolution, sourceImage.VerticalResolution);
        BitmapData sourceData = sourceImage.LockBits(rect, ImageLockMode.ReadOnly, sourceImage.PixelFormat);
        BitmapData targetData = targetImage.LockBits(rect, ImageLockMode.WriteOnly, targetImage.PixelFormat);
        Int32 actualDataWidth = ((Image.GetPixelFormatSize(sourceImage.PixelFormat) * rect.Width) + 7) / 8;
        Int32 h = sourceImage.Height;
        Int32 origStride = sourceData.Stride;
        Int32 targetStride = targetData.Stride;
        Byte[] imageData = new Byte[actualDataWidth];
        IntPtr sourcePos = sourceData.Scan0;
        IntPtr destPos = targetData.Scan0;
        // Copy line by line, skipping by stride but copying actual data width
        for (Int32 y = 0; y < h; y++)
        {
            Marshal.Copy(sourcePos, imageData, 0, actualDataWidth);
            Marshal.Copy(imageData, 0, destPos, actualDataWidth);
            sourcePos = new IntPtr(sourcePos.ToInt64() + origStride);
            destPos = new IntPtr(destPos.ToInt64() + targetStride);
        }
        targetImage.UnlockBits(targetData);
        sourceImage.UnlockBits(sourceData);
        // For indexed images, restore the palette. This is not linking to a referenced
        // object in the original image; the getter of Palette creates a new object when called.
        if ((sourceImage.PixelFormat & PixelFormat.Indexed) != 0)
            targetImage.Palette = sourceImage.Palette;
        // Restore DPI settings
        targetImage.SetResolution(sourceImage.HorizontalResolution, sourceImage.VerticalResolution);
        return targetImage;
    }

}

It seems this method only fixes the problem for 8-bit and 4-bit png, though. A png with only 4 colours re-saved by Gimp turned into a 2-bit png, and that still opened as 32-bit colour despite not containing any transparency.

There is in fact a similar issue with saving the palette size; the .Net framework can perfectly handle loading png files with a palette that's not the full size (less than 256 for 8-bit, less than 16 for 4-bit), but when saving the file it will pad it to the full palette. This can be fixed in a similar way, by post-processing the chunks after saving to a MemoryStream. This will require calculating the CRCs, though.

Also note that while this should be able to load any image type, it won't work correctly on animated GIF files, since the CloneImage function used at the end only copies a single image.