Exporting Ultra-High-BPP Gray-Scale with FreeImage

Are you trying to encode data that corresponds to a specific point? Perhaps a height map, or an MRI or CAT scan? Even a density map?

If so, you may be considering storing it in an image or video format. Of course, if you’ve tried that, you may have bumped into the sharp limit of what our monitors can represent, and for that matter, what our eyes can realistically see.

Most images have a maximum of 8-bit-channel color depth, that is, eight bits per pixel color, or 256 possible shades each, of red, green, or blue. The reason for this is that the eye does have its limit on perceivable and noise-free differences in shade. According to the Young-Helmholtz trichromatic theory¹, Our eyes have three types of cone cells responsible for detecting color, distributed across low, medium, and high wavelengths. (Overlap is why we perceive a gradual blend between colors, with intensity-detecting rod cell sensitivity somewhere in the blue range, partially explaining relative “brightness” of different shades. Hence, the rainbow!)

Each cone has a resolution of roughly 100 shades. That means that our eyes, in theory, are capable of distinguishing between 100³ or 1,000,000 different colors. For the rare tetrachrome with a fourth cone cell type, it’s possibly closer to 100,000,000. In any case, 256 shades for red, green, and blue (corresponding to low, medium, and high wavelength color) gives us around 256³ or roughly 16,700,000 possible colors. All the same, for the trained eye, monitors do exist that can render high-bit-depth color, extending into the 12-bit-per-channel range; but beware that they are not cheap.

The drive for higher bit depth usually stops with scientists; however, when we’re talking about storing data (rather than appearance) in an image, eight bits only get us two hundred and fifty six possible values. For more extreme precision, 16-bit (or even higher) can look awfully appealing.

But enough introduction. How does one export a high-bit-per-pixel single-channel image? This is fairly easy to do, and generally cross-platform, with a toolkit called FreeImage. However, like everything else, it recognizes that most of its usage comes down to displays, and it isn’t immediately obvious how to do so.

There’s a specific (and very efficient) function in that toolkit which can set a pixel color according to a provided parameter. It’s usually easy to use. The specification is thus:

DLL_API BOOL DLL_CALLCONV FreeImage_SetPixelColor(FIBITMAP *dib, unsigned x, unsigned y, RGBQUAD *value);

Where FIBITMAP is a FreeImage bit mapping, x and y are unsigned values specifying coordinate from the bottom-left, and RGBQUAD is… RGBQUAD is, ah… uh oh.

RGBQUAD is 32-bits. It contains one byte for each primary color, and one byte reserved (where you could in theory store the alpha channel or any other special value). However, that limits us to, one, color; and two, no more than eight bits per channel. So, if we’re dealing with 16-bit gray-scale, we can’t use it; and if you try to pass in a pointer to a ushort you’re asking for trouble on compilation.

To modify monochromatic data, broadly speaking, we have to edit the scan lines directly. RGBQUAD has to be purged from the code for anything high-bit-def. There is precious little data on doing this available; but it comes down to another wonderful method included in FreeImage, specifically for cases like this one.

DLL_API BYTE *DLL_CALLCONV FreeImage_GetScanLine(FIBITMAP *dib, int scanline);

A scan line is the line drawn, classically by the CRT beam in older monitors, across the width of the screen. It carried over to image and texture terminology, where alignment matches a monitor’s in most cases. So, a scan line is the range of data corresponding to one “sweep” of the screen.

This method returns a BYTE *, which many will recognize as a pointer to a specific byte in memory. If you need a pointer to, say, a ushort (16 bits), you can pretty much just cast it in most languages. So, with a little pointer arithmetic, we can access the location of every quantized color value, regardless of what bit count it’s made of, in memory!

The code that ultimately ended up working for me, in D, was this.

for(int y = 0; y < height; y++) {
	ubyte *scanline = FreeImage_GetScanLine(bitmap, y);
	for(int x = 0; x < width; x++) {
		ushort v = cast(ushort)(data[cast(ulong)((y * width + x)] * 0xFFFF);
		ubyte[2] bytes = nativeToLittleEndian(v);
		scanline[x * ushort.sizeof + 0] = bytes[0];
		scanline[x * ushort.sizeof + 1] = bytes[1];
	}
}

For each scan line in the image, we iterate over the number of pixels in its width. (You must know this ahead of time.) We calculate our value and store it in v. (data was sent to the function in a parameter, as a double[]. It’s the data to be rendered in the image, as a list of values, of dimension height × width of the image.)

v is a ushort, so it’s already two bytes and interpreted as unsigned. Of course, a ushort can’t be dropped into a byte slot, it needs twice the space, but the equivalent byte array, available through std.batmanip.nativeToLittleEndian, will do just fine. My machine is working with little-endian format, so this is all that’s needed.

I suppose you could also do it manually with a little pointer math, if you enjoy that kind of thing or don’t have an appropriate package in your environment.

We can then marshal those bytes into their equivalent positions on the scanline array. We’re done! The image can be exported to a file.

Unfortunately, you may bump into an issue where the output is upside down. This isn’t as noticeable for noise arrays, but stands out on anything with actual dimensions. To change this, you need to fill from the bottom-up instead of the top down. This is fixable with a single line of change.

ushort v = cast(ushort)(data[cast(ulong)(((height - 1) - y) * width + x)] * 0xFFFF);

By decrementing from the maximum value of y, you can reverse the order of iteration and flip your image.

PNG and TIFF are two good formats for 16-bit-depth gray-scale images. It’s hypothetically also possible with higher bit depths, but the procedure should rough be the same, and there are too many possibilities to tangle with right now. I recently even heard something about 96-bit-depth monochrome, but that’s 2⁹⁶ or ~8 × 10²⁸ values and I’m not sure we can even get that kind of precision on most measurements. (Even Mössbauer spectroscopy only goes to change detection of a few parts per 10¹¹, and last I checked, that’s the finest measurement any scientist has yet made.) I suppose it’s a comfort to know that, if we ever are that certain about anything, modeling it as an image is a possibility.

Also note that higher-than-16-bit channels are very difficult to read with most image software. I start bumping into trouble around 32-bit, and others can expect the same. So, roll your own, or come up with a better method of parsing the data, like a hex editor.

Final Code

void toFreeImagePNG(string fileName, const double width, const double height, double[] data) {
     FIBITMAP *bitmap = FreeImage_AllocateT(FIT_UINT16, cast(int)width, cast(int)height);
     for(int y = 0; y < height; y++) {
         ubyte *scanline = FreeImage_GetScanLine(bitmap, y);
         for(int x = 0; x < width; x++) {
             ushort v = cast(ushort)(data[cast(ulong)(((height - 1) - y) * width + x)] * 0xFFFF);
             ubyte[2] bytes = nativeToLittleEndian(v);
             scanline[x * ushort.sizeof + 0] = bytes[0];
             scanline[x * ushort.sizeof + 1] = bytes[1];
         }
     }
     FreeImage_Save(FIF_PNG, bitmap, fileName.toStringz);
     FreeImage_Unload(bitmap);
 }

Fun fact: The image used for this page is 16-bit, though the data doesn’t really necessitate it. (One of my generated topographies; I’ve noticed plateauing on my data, and am upping the output to 16-bit, with this, to remove it. The resolution still needs to be scaled up above 256×256, though.)

You can check this, on most Linux distros, with:

$file multarraya_fi.png
 multArrayA_FI.png: PNG image data, 256 x 256, 16-bit grayscale, non-interlaced

Happy sciencing!

¹ Kalat, James W. (2001). Biological Psychology, Seventh Edition, Bellmont, CA: Wadsworth/Thompson Learning

Published by Michael Macha

I'm a game developer for both mobile and PC. My education is in physics, journalism, and neuroscience. Founder and CEO of Frontier Medicine Entertainment, located in the beautiful city of Santa Fe, New Mexico.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: