Image filtering in the browser starts with a simple loop: paint an image into a <canvas>, read the pixels back out, transform them in JavaScript, and write the result to a canvas. Once that round trip works, most classical filters are only a few lines of arithmetic.
Reading pixels and feeding them to a filter
Pixels come back from the canvas as a flat array of red, green, blue and alpha values:
Filters = {};
Filters.getPixels = function(img) {
var c = this.getCanvas(img.width, img.height);
var ctx = c.getContext('2d');
ctx.drawImage(img);
return ctx.getImageData(0,0,c.width,c.height);
};
Filters.getCanvas = function(w,h) {
var c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
};
A single entry point is enough to drive every filter that follows. filterImage(image, filter) takes the source image and a function that consumes one [r, g, b, a] pixel and returns its filtered counterpart:
Filters.filterImage = function(filter, image, var_args) {
var args = [this.getPixels(image)];
for (var i=2; i<arguments.length; i++) {
args.push(arguments[i]);
}
return filter.apply(null, args);
};
Working by hand: grayscale, brightness, threshold
Grayscale collapses the three color channels into a single luminance value and copies it back into each channel, keeping alpha untouched:
Filters.grayscale = function(pixels, args) {
var d = pixels.data;
for (var i=0; i<d.length; i+=4) {
var r = d[i];
var g = d[i+1];
var b = d[i+2];
// CIE luminance for the RGB
// The human eye is bad at seeing red and blue, so we de-emphasize them.
var v = 0.2126*r + 0.7152*g + 0.0722*b;
d[i] = d[i+1] = d[i+2] = v
}
return pixels;
};
Brightness shifts every channel by a constant offset:
Filters.brightness = function(pixels, adjustment) {
var d = pixels.data;
for (var i=0; i<d.length; i+=4) {
d[i] += adjustment;
d[i+1] += adjustment;
d[i+2] += adjustment;
}
return pixels;
};
Thresholding compares a pixel's grayscale value against a cutoff and pushes the color to one extreme or the other:
Filters.threshold = function(pixels, threshold) {
var d = pixels.data;
for (var i=0; i<d.length; i+=4) {
var r = d[i];
var g = d[i+1];
var b = d[i+2];
var v = (0.2126*r + 0.7152*g + 0.0722*b >= threshold) ? 255 : 0;
d[i] = d[i+1] = d[i+2] = v
}
return pixels;
};
Convolution as a general purpose filter
Convolution takes the weighted sum of a rectangular neighborhood around each source pixel and writes it to the output. Because the weights are arbitrary, the same routine covers blurring, sharpening, embossing and edge detection. The example below extends the image with a border so the neighborhood stays in bounds, and centers the kernel values around the source coordinates:
Filters.tmpCanvas = document.createElement('canvas');
Filters.tmpCtx = Filters.tmpCanvas.getContext('2d');
Filters.createImageData = function(w,h) {
return this.tmpCtx.createImageData(w,h);
};
Filters.convolute = function(pixels, weights, opaque) {
var side = Math.round(Math.sqrt(weights.length));
var halfSide = Math.floor(side/2);
var src = pixels.data;
var sw = pixels.width;
var sh = pixels.height;
// pad output by the convolution matrix
var w = sw;
var h = sh;
var output = Filters.createImageData(w, h);
var dst = output.data;
// go through the destination image pixels
var alphaFac = opaque ? 1 : 0;
for (var y=0; y<h; y++) {
for (var x=0; x<w; x++) {
var sy = y;
var sx = x;
var dstOff = (y*w+x)*4;
// calculate the weighed sum of the source image pixels that
// fall under the convolution matrix
var r=0, g=0, b=0, a=0;
for (var cy=0; cy<side; cy++) {
for (var cx=0; cx<side; cx++) {
var scy = sy + cy - halfSide;
var scx = sx + cx - halfSide;
if (scy >= 0 && scy < sh && scx >= 0 && scx < sw) {
var srcOff = (scy*sw+scx)*4;
var wt = weights[cy*side+cx];
r += src[srcOff] * wt;
g += src[srcOff+1] * wt;
b += src[srcOff+2] * wt;
a += src[srcOff+3] * wt;
}
}
}
dst[dstOff] = r;
dst[dstOff+1] = g;
dst[dstOff+2] = b;
dst[dstOff+3] = a + alphaFac*(255-a);
}
}
return output;
};
A 3x3 sharpen kernel pushes most of the weight onto the center pixel. Keeping the kernel values summing to one preserves the overall brightness:
Filters.filterImage(Filters.convolute, image,
[ 0, -1, 0,
-1, 5, -1,
0, -1, 0 ]
);
The box blur is the averaging counterpart: an NxN kernel in which every weight is 1 / (NxN). Each neighbor then contributes equally, and the weights again sum to one:
Filters.filterImage(Filters.convolute, image,
[ 1/9, 1/9, 1/9,
1/9, 1/9, 1/9,
1/9, 1/9, 1/9 ]
);
Composing filters: Sobel edge detection
Filters can be chained. A Sobel filter grayscales the image, computes horizontal and vertical gradients, and combines the two gradient images into an edge map.
Gradient here means the change in pixel value at a position. A pixel whose left neighbor is 20 and whose right neighbor is 50 has a horizontal gradient of 30. The vertical gradient uses the neighbors above and below in the same way.
var grayscale = Filters.filterImage(Filter.grayscale, image);
// Note that ImageData values are clamped between 0 and 255, so we need
// to use a Float32Array for the gradient values because they
// range between -255 and 255.
var vertical = Filters.convoluteFloat32(grayscale,
[ -1, 0, 1,
-2, 0, 2,
-1, 0, 1 ]);
var horizontal = Filters.convoluteFloat32(grayscale,
[ -1, -2, -1,
0, 0, 0,
1, 2, 1 ]);
var final_image = Filters.createImageData(vertical.width, vertical.height);
for (var i=0; i<final_image.data.length; i+=4) {
// make the vertical gradient red
var v = Math.abs(vertical.data[i]);
final_image.data[i] = v;
// make the horizontal gradient green
var h = Math.abs(horizontal.data[i]);
final_image.data[i+1] = h;
// and mix in some blue for aesthetics
final_image.data[i+2] = (v+h)/4;
final_image.data[i+3] = 255; // opaque alpha
}
Other kernels work through the same convolution routine; the discrete Laplace operator is a common next exercise, since its behavior emerges entirely from the weights.
Beyond the 2D context
When JavaScript loops stop being fast enough, the same filters can be moved to WebGL fragment shaders. Most simple filters then run in realtime, which makes them practical for post-processing live video and animations.



