To explain it, lets start with the destination, the 8 bits. Let's say we have a monochrome pixel (2 dimensional) array that will be displayed in a browser. Each pixel in the array is represented by a single unsigned byte represented, for our use, as a decimal number from 0 to 255. If we a 0 in an element in the array, then that pixel is black, put a 255 there and it is white. A simple example might be something like this:
[0, 255, 0 ]
[255, 255, 255]
[0, 255, 0 ]
which would display as a very small white plus sign ('+') on a black background.
Simple enough, but the real pixel data we have is a 2 dimensional array of unsigned integers with 12 significant bits. In our case we'll represent that as decimal numbers from 0 to 4095. Somehow we need to throw away 4 bits of data. Let's start from another really simple example.
The (very small) image data we get from the source looks like this:
[31 , 3134, 19 ]
[3234, 3432, 3187]
[29 , 3296, 19 ]
If we could display this on a (very expensive) 12 bit display card and monitor then we would see a light gray plus sign on a very dark gray background. As it is, we have only 8 bit monitors and cards, so lets throw away some bits!
The way I finally understood this is to think of the incoming data in terms of its histogram. A histogram is a one dimensional array that is the size of the pixel depth (4096 for our incoming data or 256 for our data as it will be displayed) and contains a count of the number of pixels with each value. The array elements may be thought of as "buckets", each pixel of the incoming image is thrown in a bucket corresponding to its brightness. For our incoming data example above that would be an array where the 19th bucket has 2 pixels, the 29th bucket has 1, the 31st has 1, the 3134th has 1, the 3187th has 1 etc .... and all the rest would have 0.
What we need to do is decide where each pixel in the source histogram would fit in the destination histogram (i.e which source buckets we empty into a destination bucket.) A simple answer would be to take the first 16 buckets from the source and dump them all into the first destination bucket, the second 16 source buckets into the second destination bucket, etc ...) This works and is, in fact, a very simple windowing scheme where the "window" is the entire range of input data.
Now, let's say that we somehow decide that the important data is carried in the input buckets that range only from the 85th to the 3767th (for now, don't ask how we got these numbers). In other terms we have a "window" of 3682 buckets of input to put into the 255 buckets of destination. To do so we generalize the algorithm we used earlier, where we just dump 16 source buckets into 1 destination bucket, to arbitrary source and destination bucket array sizes.
Here comes the code:
// first we need the parameters for our data structures
// the bucket range is the window size divided by the pixel depth of the
// destination display
double bucketSize = getWindow() / getImageStats().getDisplayGrayScaleLevels();
int maxPixelValue = 2 ^ getImageStats().getDisplayGrayScaleLevels();
int minPixelValue = 0;
// the minimum and maximum window level are the level minus and plus half the
// window, respectively. i.e. the level is the center, the window is the range around
// that center.
double minWindowValue = getLevel() - (getWindow() / 2.0);
double maxWindowValue = minWindowValue + getWindow();
// loop through the pixel array by row and then column
for (int y = 0; y < height; ++y)
{
int rowOffset = y * (int)width;
for (int x = 0; x < width; ++x)
{
// the input pixel values are in the pixelData array
double pixelValue = pixelData[rowOffset + x];
double value = 0;
if(pixelValue > maxWindowValue)
value = maxPixelValue;
else if(pixelValue < minWindowValue)
value = minPixelValue;
else
value = (pixelValue - minWindowValue) / bucketSize;
outputPixelData[rowOffset+x] = value;
}
}