Translate

Image of Beginning Google Maps API 3
Image of Linux Kernel Development (3rd Edition)
Image of Advanced Programming in the UNIX Environment, Second Edition (Addison-Wesley Professional Computing Series)
Image of Modern Operating Systems (3rd Edition)

HTML5 Canvas To PPM Serializer

Earlier this week I added support for saving images in Portable Pixel Map (PPM) format to a Firefox Add-on called WIPS which I wrote earlier this year. Why bother adding PPM support you may well ask? Well, the PPM format is probably one of the lowest common denominators amongst the image exchange formats that are in common use on Unix and GNU/Linux platforms.

I wrote the PPM image serializer in Javascript so that it would be portable across all the platforms supported by Firefox. I could have called out to a shared library or DDL, for example netpbm‘s libnetpbm, but that would have required me to check for the shared library and then handle the case where the shared library is unavailable.

There is no formalized specification, i.e. a specification developed by a SDO such as IEC or IEEE, for the PPM image format but a de facto specification can be found on SourceForge.

Here is the source for the HTML5 canvas to PPM serializer:

    createPPM : function(canvas, destFile, usegamma)
    {
        const MAXVAL = 255;

        var width = canvas.width;
        var height = canvas.height;

        var ctx = canvas.getContext("2d");
        var canvasData = ctx.getImageData(0, 0, width, height);

        // build the Bt.709 gamma tables
        if (this.ppmgamma) {
            var rGammaTable = new Array(MAXVAL + 1);
            var gGammaTable = new Array(MAXVAL + 1);
            var bGammaTable = new Array(MAXVAL + 1);

            this.buildBT709GammaTable(rGammaTable, MAXVAL);
            this.buildBT709GammaTable(gGammaTable, MAXVAL);
            this.buildBT709GammaTable(bGammaTable, MAXVAL);
        }

        // build file header
        var fileHeader = "P6\n";
        if (usegamma) {
           fileHeader += "# BT.709 gamma adjustment applied\n";
        } else {
           fileHeader += "# No gamma adjustment applied\n";
        }
        fileHeader += width + " " + height + "\n";     
        fileHeader += MAXVAL + "\n";          

        // build data
        var imgData = canvasData.data;
        var pixelData = [];
        for (var i = 0, n = imgData.length; i < n; i += 4) {
            pixelData.push(imgData[i]);                              // red
            pixelData.push(imgData[i+1]);                            // green
            pixelData.push(imgData[i+2]);                            // blue
            // skip alpha channel
        }
   
        // now fix up gamma values
        if (usegamma) {
            for (var i = 0; i < pixelData.length; i += 3) {
                pixelData[i]   = rGammaTable[pixelData[i]];
                pixelData[i+1] = gGammaTable[pixelData[i+1]]; 
                pixelData[i+2] = bGammaTable[pixelData[i+2]];
            }
        }

        var file = Components.classes["@mozilla.org/file/local;1"]
                             .createInstance(Components.interfaces.nsILocalFile);
        file.initWithPath(destFile);
        if (file.exists()) {
            file.remove(true);
        }
        file.create(file.NORMAL_FILE_TYPE, 0666);

        var fileStream = Components.classes['@mozilla.org/network/file-output-stream;1']
                                   .createInstance(Components.interfaces.nsIFileOutputStream);
        fileStream.init(file, 2, 0x200, false);

        var binaryStream = Components.classes['@mozilla.org/binaryoutputstream;1']
                                     .createInstance(Components.interfaces.nsIBinaryOutputStream);
        binaryStream.setOutputStream(fileStream);

        binaryStream.write(fileHeader, fileHeader.length);
        binaryStream.writeByteArray(pixelData, pixelData.length);

        binaryStream.close();
        fileStream.close();
      
    },


    //
    // Note - formulas come from netpbm package  
    //
    buildBT709GammaTable : function(table, maxval) 
    {
        var i = 0;

        var gamma = 1/0.45;                                          // Per ITU BT.709

        var oneOverGamma = 1/gamma;
        var linearCutoff = maxval * 0.018 + 0.5;
        var linearExpansion = (1.099 * Math.pow(0.018, oneOverGamma) - 0.099) / 0.018;

        for (i = 0; i <= linearCutoff; ++i) { 
            table[i] = i * linearExpansion;
        }

        for (; i <= maxval; ++i) {
            var normalized = i / maxval;    
            var v = 1.099 * Math.pow(normalized, oneOverGamma) - 0.099;
            table[i] = Math.min((v * maxval + 0.5), maxval);
        }
    }


The createPPM function expects three arguments: a HTML5 canvas element, a filename where the image is to be stored, and a boolean which indicates whether gamma adjustment is required or not. I am going to assume that you are familiar with the canvas element; if not Mozilla has a good tutorial on it and how to use it from within XUL.

The function first builds the file header (fileHeader) – which in the case of PPM is very simple. Here is the specification for the file header:

  • A magic number to identify the file type. The magic number is the two characters P6 for PPM.
  • One or more whitespace (BLANK, TAB, NEWLINE, CR, LF). I choose to use a NEWLINE
  • The image WIDTH, formatted as ASCII decimal, followed by whitespace, followed by the image HEIGHT, formatted as ASCII decimal. I choose to put a BLANK between WIDTH and HEIGHT.
  • One or more whitespaces. I choose to use a NEWLINE.
  • The maximum color value (maxval), formatted as ASCII decimal. Must be less than 65536 and more than zero.
  • A single whitespace character. I choose to use a NEWLINE.

The file header is followed by a vector of HEIGHT rows, in order from top to bottom. Each row consists of WIDTH pixels, in order from left to right. Each pixel is a triplet of red, green, and blue samples, in that order. There is no support for an alpha channel. Each sample is represented in pure binary by either 1 or 2 bytes. If maxval is less than 256, it is 1 byte; otherwise 2 bytes with the most significant byte first. In the case of the above code, maxval is a constant, MAXVAL, whose value is 255. Hence a pixel sample is only one byte in size.

The PPM specification specifies that the sample values in a file represent specific light intensities in an image – thus gamma adjustment of the samples is required. In particular, they specify that the sample values are directly proportional to luminance as defined by the ITU-R Recommendation BT.709. Here luminance as a function of radiance is a power function modified with a linear ramp near black. A value of maxval for all three components, i.e. in our case 255,255,255, represents CIE D65 white and the most intense color in the color universe (all the colors in all images to which this image might be compared) of which this image is part of. The buildBT709GammaTable function shown above builds a BT709 gamma adjustment lookup table.

Confused about what gamma is? A brief explanation may help. One of the simplest ways to code an image for use on a computer is to use sample values that are directly proportional to the radiance of the color components of a pixel, e.g. just code the red, green and blue components of a pixel. Radiance is a physical measurement based on the amount of power in a light. However radiance does not take into account what the light looks like to a person because the human eye cannot discern differences between low-radiance colors as well as it can differences between high-radiance colors. To compensate for the difference, a gamma transfer function is used.

The transformation process is called gamma adjusting. The gamma-adjusted value, proportional to subjective brightness, is known as the luminance of a pixel. The result is that a change in a sample should cause the same level of perceived color change anywhere in the sample range. Unlike radiance, there is no precise objective way to measure luminance since the perception of brightness varies according to a variety of factors, including the surroundings in which an image is viewed. For these reasons, there is not just one gamma transfer function.

Virtually all image formats, including PPM, use gamma-adjusted values for the sample values. If you want to learn more about this subject, a good starting place is Charles Poynton’s Gamma FAQ. Another good place is Wikipedia’s article on gamma correction.

A common variation for PPM images is to use sample values which are directly proportional to radiance (AKA linear intensity). This means no gamma adjustment. This is supported in createPPM by the boolean usegamma which, in WIPS, is actually a preference setting. Standalone utilities such as netpbm’s pnmgamma can be used to convert a linear PPM into a gamma-adjusted PPM.

Another common variation for PPM images is to make the samples proportional to luminance as defined by the IEC (International Electrotechnical Commission) sRGB standard. The sRGB gamma transfer function is identical to the BT.709 gamma transfer function except that it uses different constants.

sRGB is a color space that is intended to model the screen of an average office PC. Not all PCs are the identical and therefore they do not have a common color space. sRGB is simply an approximation of an average case. An Apple Mac can be configured to conform to the sRGB color space but is not conformant by default. According to the CSS2 specification and the Draft CSS3 Specification CSS color values refer to the sRGB color space.

Here is a JavaScript function to build a sRGB gamma adjustment lookup table:

    //
    // Note -formulas come from netpbm package  
    //
    buildSRGBGammaTable : function(table, maxval) 
    {
        var i = 0;

        var gamma = 2.4;                                           // per IEC sRGB
 
        var oneOverGamma = 1/gamma;
        var linearCutoff = maxval * 0.0031208 + 0.5;
        var linearExpansion = (1.055 * Math.pow(0.0031308, oneOverGamma) - 0.055) / 0.031308;

        for (i = 0; i <= linearCutoff; ++i) { 
            table[i] = i * linearExpansion + 0.5;
        }

        for (; i <= maxval; ++i) {
            var normalized = i / maxval;    
            var v = 1.055 * Math.pow(normalized, oneOverGamma) - 0.055;
            table[i] = Math.min((v * maxval + 0.5), maxval);
        }
    }


The JavaScript source code in this post should be self explanatory. I see no value in explaining it in detail. I used the JavaScript prototypal pattern to specify objects in WIPS. How to convert the example code to standard JavaScript functions should self-evident. Feel free to use the code in any of your projects provided it is attributed to me.

Enjoy!
 

Comments are closed.