Introduction
If you have a lot of code invested in WinForms and GDI+ and are frustrated by the lack of deep color (> 24 bits-per-pixel) support in GDI+, then read on for the principles of a workaround, including some code snippets but NO completely coded solution (it's not completely mine to give away!). I am assuming the reader has some background in GDI+ and GDI+ image processing.
Background
GDI+ is the technology behind the drawing and imaging in WinForm applications using the System.Drawing namespace. It is quite old, and doesn't seem to get updated anymore. It's supplanted in WPF by the System.Windows.Media namespace classes, which are NOT compatible with the older GDI+ classes. In other words, if you have, like me, written a lot of image processing routines that work with GDI+ bitmaps you will need to rewrite them to work with the WPF media images (WriteableBitmap
). Moreover, if your UI layer is in Winforms you'll have to adapt those to show WPF bitmaps. Suffice to say it's a lot of work, and I'm not at all sure it's worth it with all the changes going on in .NET (Metro?).
Now GDI+ bitmaps have, at first glance support for 48 bits-per-pixel (bpp) images (there is a Format48bppRgb
pixel format). This is deceptive, because:
- there is no way to open and save 48 bpp images in GDI+.
- it's not actually 48 bpp, but rather 39 bpp with a gamma of 1.
That's right, the 48 bpp image format uses linear light encoding and this means that with 13 bits per pixel per color channel we actually gain nothing, zero, zilch with respect to the standard 24 bpp image format which uses a gamma of 2.2. So if you, like me, are in need of deep color support for higher precision (or greater dynamic range), you are out of luck! Read on for a workaround that will allow loading and saving of images at higher pixel depths, as well as correct displaying them.
The solution
The first problem to tackle was the loading and saving of images. WPF has support for deep color, so I simply used WPF to create GDI+ bitmaps by writing conversion routines between GDI+ and WPF bitmaps. This is actually rather simple, and just consist of copying memory between the GDI+ bitmap buffers and the WPF bitmap buffers (dstData is BitmapData
of a GDI+ bitmap, source is a WPF WriteableBitmap
).
[DllImport("kernel32.dll", EntryPoint = "CopyMemory", CallingConvention = CallingConvention.StdCall, SetLastError = true)]
internal static extern void CopyMemory(void* PtrOut, void* PtrIn, int byteCount);
CopyMemory((void*)dstData.Scan0.ToPointer(), (void*)source.BackBuffer.ToPointer(), nrBytes);
Now, I know that doesn't cater for all the formats in WPF and will cause problems if the order of color components is not the same (BGR versus RGB), but it seems to work fine for 1, 8, and 24 bpp BGR images as these are also BGR in WPF. When trying to load an 48 bpp image this way the display looked like this:
Why is this? Well as mentioned before out GDI+ only uses 39 bpp, so we need to map the WPF image data to that range:
[System.Security.SecuritySafeCriticalAttribute]
private static unsafe void ConvertData(WriteableBitmap source, BitmapData dstData, ushort[] LUT)
{
ushort* srcPixel0 = (ushort*)source.BackBuffer.ToPointer();
ushort* dstPixel0 = (ushort*)dstData.Scan0.ToPointer();
int dstStride = dstData.Stride / 2, srcStride = source.BackBufferStride / 2, width = source.PixelWidth;
unsafe
{
Action<int> processRow = y =>
{
ushort* src = srcPixel0 + y * srcStride;
ushort* dst = dstPixel0 + y * dstStride;
for (int x = 0; x < width; x++)
{
for (int i = 0; i < 3; i++) *(dst + 2 - i) = LUT[*(src + i)];
src += 3; dst += 3;
}
};
System.Threading.Tasks.Parallel.For(0, source.PixelHeight, processRow);
}
}
The LUT (for lookup table) is just a quick and efficient way to map [0 - 2^16] to [0 - 2^13], and is straightforward to implement. Doing this linearly (proportionally) keeps the same gamma-correction as the original data.
for (int i = 0; i <= ushort.MaxValue; i++) LUT[i] = (ushort)(i >> 3);
Note how RGB is reordered into BGR in the inner loop because 48 bpp is RGB in GDI+! When displaying this image, however, we again get some weirdness:
The image looks completely washed out. As it turns out, by default GDI+ interprets 48 bpp images as having gamma 1 (i.e. linear data). We do not want to store linear data as this would not mean any improvement whatsoever over 24 bpp images (see next paragraph with some technical background on why this is so). Luckily when displaying an image in GDI+ there is a way to include ImageAttributes
, and these can set or override the applied gamma-correction.
Dim attr As New Imaging.ImageAttributes
attr.SetGamma(2.2)
Me.Graphics.DrawImage(.Bitmap, rect, 0, 0, rect.Width, rect.Height, GraphicsUnit.Pixel, attr, Nothing)
Technical background on linear data and gamma-correction: Basically with linear data the perceived color change between a pixel value and the next pixel value is very uneven over all possible values of that pixel, and usually much larger for dark colors. Thus, to avoid any banding in those dark areas more bits are needed to provide smoother transitions between colors. By a freak accident of nature, the non-linear gamma correction which is usually applied to linear color tristimulus values (e.g. RGB) to compensate for cathode-ray tube display devices non-linear behavior is related to the way our visual system processes light. The result is that for gamma-corrected data the perceived color difference between a pixel value and the next pixel value is more or less constant, meaning less bits can be used to encode pixel values without creating noticeable banding. This is why even with our modern linear TFT-based display devices gamma-correction is still used because it lowers badnwidth requirements.
Displaying the 48 bpp image in our Winform application finally gets the desired result:
Evidently, in order to save 48 bpp images we need to create a WPF image from a GDI+ bitmap, and then save that. The conversion could again use a LUT, this time mapping [0 - 2^13] to [0 - 2^16].
[System.Security.SecuritySafeCriticalAttribute]
private static unsafe void ConvertData(BitmapData sourceData, WriteableBitmap destination, ushort[] LUT)
{
ushort* srcPixel0 = (ushort*)sourceData.Scan0.ToPointer();
ushort* dstPixel0 = (ushort*)destination.BackBuffer.ToPointer();
int dstStride = destination.BackBufferStride / 2, srcStride = sourceData.Stride / 2, width = sourceData.Width;
unsafe
{
Action<int> processRow = y =>
{
ushort* src = srcPixel0 + y * srcStride;
ushort* dst = dstPixel0 + y * dstStride;
for (int x = 0; x < width; x++)
{
for (int i = 0; i < 3; i++) *(dst + 2 - i) = LUT[*(src + i)];
src += 3; dst += 3;
}
};
System.Threading.Tasks.Parallel.For(0, sourceData.Height, processRow);
}
}
Typical image processing routines for 48 bpp image would now be using ushort pointers instead of the usual byte pointers, similar to the previous code snippet.
Conclusion
I understand this is just a workaround, in the end it could be more efficient to write all image processing routines on some native internal image format (e.g. double multidimensional or jagged arrays), and just provide input and output conversion routines to GDI+, WPF and whatever Microsoft comes up with next. I have done a basic implementation of this and it's about 2 times slower and obviously consumes much more memory. Conversion to and from this format is very fast, and it has the added bonus of not loosing precision when performing multiple image processing operations in sequence because we don't need to store it in an integer bitmap format after every operation. Until this is fully implemented this workaround works fine for me ...