Monday, March 4, 2013

Simple Linear Windowing and Leveling

As a newbie to the world of medical imaging, I learned of this mysterious "window and leveling" that looked a whole lot like contrast and brightness to my untrained eye.  Unfortunately, the experts around me weren't terribly enthusiastic about explaining the concept so the mystery just increased the longer I ignored it.  Working on the server side I had little need for it so it remained a mystery until I started working on an HTML5 viewer.  At that point I ran straight into the problem: "How do you fit 12 bits of pixel depth into an 8 bit bucket?"  The simple answer is windowing and leveling.

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;
            }
        

You may be suspecting now that throwing data away is a bad thing and that throwing away data in a medical image is a really bad thing.  The simple answer is yes, and YES.  We, the computer geeks, do not decide what data to throw away, we pass that decision on to someone who can decide what is significant (i.e. a radiologist).  They determine the window and level parameters, sometimes by image type, but usually as they view an image.