Realtime Image Tinting on HTML5 Canvas

Posted: by Joe under Under the Hood

We’ve improved our setColor() API function, so it can now be used to set the colour of images as well as text and primitives. See below for an example:

However the HTML5 canvas doesn’t support tinting images out of the box, so we’ve had to emulate it on top. Searching online, I found many questions but no definitive answer on how to achieve this quickly enough in realtime without compromising the quality of the image. So, how did we do it?

Common Approaches

The first method I tried for doing this is to simply take the pixels from the image, iterate over those pixels whilst adding the tint, and then re-apply the pixels back to the image (or to a new image). This works, but it’s simply too expensive.

The second method is to get a spare canvas and fill it with a coloured rectangle and draw the image on top. You can also have the canvas use the alpha value from your image. This works in realtime, but the quality is less than adequate. Here’s an example of our tinting (on the left) compared to this rectangle technique (on the right).

It’s a nice fast way of tinting an image and would be useful for certain situations, but as you can see it leaves the original image very whitewashed. We wanted a tint that was more like light, where if the draw color is just red then only the image’s red components would appear. With the rectangle approach you’ll still have some of the remaining blue and green components remaining.

So how do we do it?

First, you should be aware of the ‘globalCompositeOperation’ property on the canvas. This allows you to make the canvas mix colours in different ways when you draw to it. The downside is that it doesn’t have many options, and most are based around the alpha channels of the destination and the source (rather then on how the colours thenselves will be mixed).

The brainwave I had is that by separating out the components of an image, you can then tint it by re-mixing those components with different proportions. Separating the components out requires iterating over all pixels, but you only need to perform this once per image (and even then only those which will be tinted).

Specifically the image is split into a red, green, blue and black version. Each of these are then stored within their own canvas.

    function generateRGBKs( img ) {
        var w = img.width;
        var h = img.height;
        var rgbks = [];

        var canvas = document.createElement("canvas");
        canvas.width = w;
        canvas.height = h;
        
        var ctx = canvas.getContext("2d");
        ctx.drawImage( img, 0, 0 );
        
        var pixels = ctx.getImageData( 0, 0, w, h ).data;

        // 4 is used to ask for 3 images: red, green, blue and
        // black in that order.
        for ( var rgbI = 0; rgbI < 4; rgbI++ ) {
            var canvas = document.createElement("canvas");
            canvas.width  = w;
            canvas.height = h;
            
            var ctx = canvas.getContext('2d');
            ctx.drawImage( img, 0, 0 );
            var to = ctx.getImageData( 0, 0, w, h );
            var toData = to.data;
            
            for (
                    var i = 0, len = pixels.length;
                    i < len;
                    i += 4
            ) {
                toData[i  ] = (rgbI === 0) ? pixels[i  ] : 0;
                toData[i+1] = (rgbI === 1) ? pixels[i+1] : 0;
                toData[i+2] = (rgbI === 2) ? pixels[i+2] : 0;
                toData[i+3] =                pixels[i+3]    ;
            }
            
            ctx.putImageData( to, 0, 0 );
            
            // image is _slightly_ faster then canvas for this, so convert
            var imgComp = new Image();
            imgComp.src = canvas.toDataURL();
            
            rgbks.push( imgComp );
        }

        return rgbks;
    }

The black is needed as a base color. If the tint has very little red, green or blue then the image will end up being mostly black. This matches the light model- if there is very little red, green or blue light then the things around you will appear very black in real life.

To draw you then produce a spare canvas, and draw the black component first. To add the red, green and blue you set the 'globalCompositeOperation' to 'lighter', which adds the components to the canvas as they are drawn. Because we have separated out the components, we can now have complete control over how much of he component is used through manipulating the canvas' globalAlpha property.

    function generateTintImage( img, rgbks, red, green, blue ) {
        var buff = document.createElement( "canvas" );
        buff.width  = img.width;
        buff.height = img.height;
        
        var ctx  = buff.getContext("2d");

        ctx.globalAlpha = 1;
        ctx.globalCompositeOperation = 'copy';
        ctx.drawImage( rgbks[3], 0, 0 );

        ctx.globalCompositeOperation = 'lighter';
        if ( red > 0 ) {
            ctx.globalAlpha = red   / 255.0;
            ctx.drawImage( rgbks[0], 0, 0 );
        }
        if ( green > 0 ) {
            ctx.globalAlpha = green / 255.0;
            ctx.drawImage( rgbks[1], 0, 0 );
        }
        if ( blue > 0 ) {
            ctx.globalAlpha = blue  / 255.0;
            ctx.drawImage( rgbks[2], 0, 0 );
        }

        return buff;
    }

Finally you have your tinted image, and you simply draw it as usual.

    var img = new Image();
    img.onload = function() {
        var rgbks = generateRGBKs( img );
        var tintImg = generateTintImage( img, rgbks, 200, 50, 100 );
        
        var canvas = document.getElementById("canvas");
        var ctx = canvas.getContext("2d");
        ctx.fillStyle = "black";
        ctx.fillRect( 0, 0, 100, 100 );
        
        ctx.drawImage( tintImg, 50, 50 );
    }
    img.src = "http://example.com/my_image.png";

Don't store the black?

Rather then storing the black component within a canvas, you can generate it on the fly. This can be done by filling the spare canvas with a black rectangle and then drawing the image on top by setting the 'globalCompositeOperation' on the canvas to 'destination-in'. In this scenario, this will cause the canvas to apply the images alpha channel to the black rectangle on the canvas.

I personally found this a tiny bit slower, but it also reduces your memory footprint which is especially important for big images.

Skip the secondary canvas?

If you do store the black, then you can also just draw direct to your main canvas rather then using a secondary canvas. This is quicker then generating the tint image, but also means you can't cache the tint image.

This has the advantage of reducing the memory usage, but also increases the rendering time. For Play My Code I went with the decision to store the black and draw direct to the main canvas for small images, but use a secondary canvas and to generate black on the fly for larger images.

Firefox 4

The less intensive method of mixing colours also seems to be generally slow in Firefox. The example at the top runs at around 15fps in Firefox 4, but closer to 40fps in IE 9! Although it's usable in FireFox if you only have a couple of tinted images. Hopefully in time this will be sorted as hardware acceleration improves for browsers.

There is also a much faster method I've been building which avoids the use of lighter (and it's about 4 times faster), but so far only handles greyscale images (which is why I'm not using it). So stay tuned for that!

5 Responses to Realtime Image Tinting on HTML5 Canvas

Jayenkai

Neat stuff. Haven't gotten around to using any colour stuff yet, but it'll be fun to give it a whirl. Thanks for all the effort you're putting into all this :D

I would love to see what you are doing with the greyscale version, as that is exactly what I want to tint.

Please could you share some ideas about the grayscale version? It may be really useful for videogame developers. Thanks!

Joe

Ok, I'll share what I have next week! I've been too bogged down with developments to go back through it before now.

has the greyscale image method been posted?

Leave a Response

Your email address will not be published. Required fields are marked *