Search code examples
c#wpfbitmapdrawing

Creating an ImageSource of a Bitmap that should contain a rectangle with a border (two colors), how do I specify the pixels?


I am currently working on an automatically generated Ribbon bar with buttons that have colored icons. For that, I have a list of colors provided when generating the icon, and need to create an ImageSource out of it to display the icon.

I've found a (hopefully) good way to do it by using the BitmapSource.Create method to create a bitmap-based image source. Sadly, this API seems to be really low-level, which gives me headaches to implement my needs.
That's why I am asking here now.

I got it working to just draw a plain color. I even got the top and lower border working by changing the pixel array, but I can't for the life of it get the left and right border correct.

I tried a lot, and it currently looks like this:

Display of my icons

But before I explain more, I am going ahead to show my relevant code.

public virtual ImageSource OneColorImage(Color color, (Color Color, int Size)? border = null, int size = 32)
{
    const int dpi = 96;
    var pixelFormat = PixelFormats.Indexed1;

    var stride = CalculateStride(pixelFormat.BitsPerPixel, size);

    var pixels = new byte[size * stride];
    var colors = new List<Color> { color };

    if (border.HasValue)
    {
        var (borderColor, borderSize) = border.Value;

        // Add the border color to the palette.
        colors.Add(borderColor);

        // Now go over all pixels and specify the byte explicitly
        for (var y = 0; y < size; y++)
        {
            for (var x = 0; x < stride; x++)
            {
                var i = x + y * stride;

                // Top and bottom border
                if (y < borderSize || y >= size - borderSize)
                    pixels[i] = 0xff;

                // Left and right border
                else if (x % stride < borderSize || x % stride >= stride - borderSize)
                    pixels[i] = 0xff;

                // Else background color
                else
                    pixels[i] = 0x00;
            }
        }
    }

    var palette = new BitmapPalette(colors);

    var image = BitmapSource.Create(size, size, dpi, dpi, pixelFormat, palette, pixels, stride);

    return new CachedBitmap(image, BitmapCreateOptions.None, BitmapCacheOption.Default);
}

public virtual int CalculateStride(int bitsPerPixel, int width)
{
    var bytesPerPixel = (bitsPerPixel + 7) / 8;
    var stride = 4 * ((width * bytesPerPixel + 3) / 4);
    return stride;
}

I've googled a lot to get this working, learned how to calculate the stride and what it means, but I still seem to miss something. I want to specify those two colors and create the bitmap color palette based on those colors, then just set the switch to one color or the other, that's why I am using the PixelFormats.Indexed1 format, because it is just for a two-color bitmap.
I guess I can remove some abstraction of this method by just using fixed values for this format, but I want to make it a little more abstract if I am going to change the pixel format to more colors in the future for example, thus the stride calculation etc.

Pretty sure I am missing something with the difference between width and stride when looking for the left and right border, or I don't understand how the bytes inside a single row (stride) work and have to set the bits, not the bytes...

Any help appreciated, I am stuck after trying for hours.


Solution

  • The idea to be more "optimised" by using indexed format for an image with only 2 colours is sound, but going to 1bpp will just make it all needlessly complex, since you'll have to juggle around single bits. Instead, I suggest you use PixelFormats.Indexed8; then your image simply contains one byte per pixel, which is incredibly straightforward to handle.

    If you absolutely do want to use 1bpp, I suggest you still build up the byte array as 8bpp, but simply convert it afterwards. I posted a ConvertFrom8Bit function for that purpose in another question here on StackOverflow.

    Also note, there is no need to fill in the in-between pixels for the background colour, since a new byte array in .Net is automatically cleared, and will thus always start out containing only 0x00 bytes. Because of this, it's much more efficient to only go over the border pixels, and simply handle them in separate loops. You can even optimise this by leaving the top and bottom filled parts out of the loops for filling the sides.

    public virtual ImageSource OneColorImage(Color color, (Color Color, int Size)? border = null, int size = 32)
    {
        const Int32 dpi = 96;
        Int32 stride = CalculateStride(8, size);
    
        Byte[] pixels = new byte[size * stride];
        List<Color> colors = new List<Color> { color };
        if (border.HasValue)
        {
            var (borderColor, borderSize) = border.Value;
    
            // Add the border color to the palette.
            colors.Add(borderColor);
    
            Byte paintIndex = 1;
            // Avoid overflow
            borderSize = Math.Min(borderSize, size);
    
            // Horizontal: just fill the whole block.
    
            // Top line
            Int32 end = stride * borderSize;
            for (Int32 i = 0; i < end; ++i)
                pixels[i] = paintIndex;
    
            // Bottom line
            end = stride * size;
            for (Int32 i = stride * (size - borderSize); i < end; ++i)
                pixels[i] = paintIndex;
    
            // Vertical: Both loops are inside the same y loop. It only goes over
            // the space between the already filled top and bottom parts.
            Int32 lineStart = borderSize * stride;
            Int32 yEnd = size - borderSize;
            Int32 rightStart = size - borderSize;
            for (Int32 y = borderSize; y < yEnd; ++y)
            {
                // left line
                for (Int32 x = 0; x < borderSize; ++x)
                    pixels[lineStart + x] = paintIndex;
                // right line
                for (Int32 x = rightStart; x < size; ++x)
                    pixels[lineStart + x] = paintIndex;
                lineStart += stride;
            }
        }
        BitmapPalette palette = new BitmapPalette(colors);
        BitmapSource image = BitmapSource.Create(size, size, dpi, dpi, PixelFormats.Indexed8, palette, pixels, stride);
        return new CachedBitmap(image, BitmapCreateOptions.None, BitmapCacheOption.Default);
    }
    

    Note that I prevent excessive internal calculations here; the end-value for the loops for the top and bottom is calculated once in advance, and the write offset for the left and right lines avoid all y * stride + x type calculations inside loops by simply keeping the "start of line" value for the current iteration and incrementing that by the stride on each loop.

    For the record, even though it doesn't matter for 8 bit, your stride calculation is wrong: you round the bytesPerPixel up to a full byte before adjusting it to the width, while the correct bytesPerPixel value for a one bit per pixel image would be "1/8th". Here's the correct 4-byte boundary stride calculation:

    public virtual int CalculateStride(int bitsPerPixel, int width)
    {
        Int32 minimumStride  = ((bitsPerPixel * width) + 7) / 8;
        return ((minimumStride + 3) / 4) * 4;
    }
    

    On that note, I'm not sure if the "stride must be a multiple of four" is actually enforced, or if it adjusts it automatically when you create the bitmap, but the linked ConvertFrom8Bit function will output a byte array with minimum stride, so if that's a problem, you'll just have to adjust it using the function I gave above.