Image manipulation using HTML5 Canvas

I have recently started researching some computer vision, and in the process of discovering some interesting things I have been compiling some functions that allow me to manipulate images at a low level. I am not interested in replacing any of these functions with already existing, built-in functionality of canvas (but if there are some it is always interesting to learn about them), I am trying to get a bit more low-level here.

I usually do front-ends of feature sites, so I have only cursory experience with the creation of large libraries, and I am wondering whether the style of code and way of implementing it is acceptable and what could - and what should - be improved. I have tested these functions and they do what I intended, so to me it is not about how they function precisely (I am not a mathematician, and I'm sure mathematicians can figure out better and more clever ways to reach these results). By all means, tell me about better ways, but I am trying to see if this would work as a library. I am not afraid to change functions to use core canvas constructs, but my reason for building this was to learn to manipulate pixel based graphics myself, without someone who has written native code for me.

If you run the snippet you will see some use case examples (in order of code they are: normal, blur (radius of 2), grayscale, desaturate (50%) and colour channel (red) - although they may appear in different order depending on which one loads faster). I had to base64 encode an image as I couldn't find a way around cross-origin restrictions otherwise, but of course it works with local loading of images.

The end result would be that you could use this to load an image and apply some effects, and then insert it into the DOM with the following syntax:

Usage

var myImage = new ImageManipulation({
image: 'http::domain.tld/image.ext',
callback: function(Image){
// Manipulate the image on load and append to DOM
Image.Blur(2).Desaturate(55).DOM(document.body);
}
});


Code

"use strict";

function ImageManipulation(options){

var canvasSupport   = document.createElement('canvas');
if(!(canvasSupport.getContext && canvasSupport.getContext('2d')))
throw('Initialisation failed: support for <canvas> is missing.');

this.src            = options.image || false;

if(!this.src)
throw('Initialisation failed: no image in options.');

/* Canvas for input. */
this.input          = document.createElement('canvas');
/* Canvas for output. */
this.output         = document.createElement('canvas');
/* Canvas Context for input. */
this.inputContext   = this.input.getContext('2d');
/* Canvas Context for output. */
this.outputContext  = this.output.getContext('2d');
/* Defaults (changed on Init). */
this.width          = 0;
this.height         = 0;
/* Source should not be changed after Init(). */
this.source         = false;
/* Manipulation should be done on resource after Init() */
this.resource       = false;
this.image          = document.createElement('img');
});
/* options.development:Func - defaults to 'throw'. */
this.development    = options.development && options.development instanceof Function
? options.development
: 'throw';
this.autoUpdate      = options.autoUpdate || false;
this.callback        = options.callback && options.callback instanceof Function
? options.callback
: function(){};

this.image           .setAttribute('src', this.src);

// Returns this
return this;

}

ImageManipulation.prototype = {

/***********************************************************/
/********************** Core Functions *********************/
/***********************************************************/
/* Init()     -> void
* gets called automatically. Initialises all values and resources. */
Init:                 function(){
return this.Throw("Init was ready. Cannot be called again.");

this.width           = this.image.width;
this.height          = this.image.height;
this.input.width     = this.width;
this.input.height    = this.height;
this.output.width    = this.width;
this.output.height   = this.height;
this.inputContext    .drawImage(this.image, 0, 0);
this.source          = this.inputContext.getImageData(0, 0, this.width, this.height);
this.resource        = this.inputContext.getImageData(0, 0, this.width, this.height);

this.Support.Self   = this;

this.callback(this);
},
/* Apply()     -> this
* save the changes to the source. */
Apply:                 function(){
this.source = this.resource;
if(this.autoUpdate) this.Draw();
return this;
},
/* Draw()     -> this
* update the canvas. */
Draw:                 function(){
this.outputContext.putImageData(new ImageData(
this.resource.data, this.width, this.height
), 0, 0);
return this;
},
/* Reset()     -> this
* reset the image to the original values (undo all manipulations). */
Reset:                 function(){
this.resource = this.source;
if(this.autoUpdate) this.Draw();
return this;
},
/* DOM(element?:<dom>)     -> <canvas>
* returns the <canvas> DOM element or appends it to the passed element. */
DOM:                 function(element){
this.Draw();
if(element){
element.appendChild(this.output);
return true;
} else {
return this.output;
}
},
/* Throw(message:String) -> this?
* internal error reporting function. */
Throw:                 function(message){
if(this.development === 'throw') throw(message);
else this.development(message);
return this;
},
/* Warn(message:String) -> this?
* internal warning reporting function. */
Warn:                 function(message){
if(console && console.log) console.log(message);
return this;
},
* return true or false and throws an error if a callee is passed. */
if(callee){
}
return false;
} else {
return this;
}
},

/***********************************************************/
/*********************** Manipulations *********************/
/***********************************************************/

return this.Throw("Blur @param radius:Int is required.");
return this.Throw("Blur @param radius:Int needs to be even.");

this.Warn("Blur @param radius:Int is large and may cause slowdown.");

for(var e = 0; e < this.source.data.length; e+=4){
var a = [0,0,0,255];
var position = e - (this.width * y + x) * 4;
for(var rgba = 0; rgba < 3; rgba++){
if(this.source.data[position+rgba])
a[rgba] += this.source.data[position+rgba] * blur;
else a[rgba] += 255 * blur;
}
}}
for(var rgba = 0; rgba < 4; rgba++){
this.resource.data[e+rgba] = a[rgba];
}
}
if(this.autoUpdate) this.Draw();
return this;
},
/* GrayScale([forHumans:Bool])         -> this
* Turn into grayscale. forHumans uses different values for human perception. */
GrayScale:             function(forHumans){

return this.Desaturate(100, forHumans);

},
/* Desaturate(percentage:Int, [forHumans:Bool])         -> this
* Equalise r, g and b values to the average by the passed percentage. */
Desaturate:            function(percentage, forHumans){

forHumans = forHumans
? [.3,.4,.3]
: [(1/3),(1/3),(1/3)];

for(var p = 0; p < this.source.data.length; p+=4){
var avg = 0;
for(var i = 0; i < 3; i++){
avg += this.source.data[p+i] * forHumans[i];
}
for(var i = 0; i < 3; i++){
this.resource.data[p + i] = Math.round(this.source.data[p + i] + (avg - this.source.data[p + i]) / 100 * percentage);
}
}
if(this.autoUpdate) this.Draw();
return this;

},
/* Channel(channel:Char:r,g,b,a)         -> this
* Display channel in single color. Accepts r,g,b or a. */
Channel:             function(channel){

if(channel == 'r') channel = 0;
if(channel == 'g') channel = 1;
if(channel == 'b') channel = 2;
if(channel == 'a') channel = 3;
if(channel != 0 && channel != 1 && channel != 2 && channel != 3)
return this.Throw('Channel has to be either r, g, b or a');
for(var i = 0; i < this.source.data.length; i += 4){
var alpha                = channel == 3 ? this.source.data[i+3]     : 0;
this.resource.data[i]     = channel == 0 ? this.source.data[i]         : alpha;
this.resource.data[i+1] = channel == 1 ? this.source.data[i+1]     : alpha;
this.resource.data[i+2] = channel == 2 ? this.source.data[i+2]     : alpha;
this.resource.data[i+3] = 255;
}
if(this.autoUpdate) this.Draw();
return this;
},
/***********************************************************/
/********************* Support Functions *******************/
/***********************************************************/
Support: {
Self: false,
getPixelAtAxisFromPixel: function(id, angle){
Self = this.Self || {width: 0};
angle = angle%360;
if(angle%45) Self.Warn("getPixelAtAxisFromPixel @angle:Int[45*n] converted to Axis");
angle = Math.round(angle / 45) * 45;
switch(angle){
case 0:     add = 1; break;
case 45:    add = Self.width + 1; break;
case 90:    add = Self.width; break;
case 135:   add = Self.width - 1; break;
case 180:   add = -1; break;
case 225:   add = -Self.width - 1; break;
case 270:   add = -Self.width; break;
case 315:   add = -Self.width + 1; break;
case 360:   add = 1; break;
}
// Check whether there is a linedifference of more than 2 (edge-case)
id = Math.floor(id / Self.width);
return false;
} else {
}
}
}
};

/***********************************************************\
* Please note the below is for the example only and not   *
* really relevant to comment on.                          *
\***********************************************************/

new ImageManipulation({
image: image,
callback: function(Image){
Image.DOM(document.body);
}
});
new ImageManipulation({
image: image,
callback: function(Image){
Image.Blur(2).DOM(document.body);
}
});
new ImageManipulation({
image: image,
callback: function(Image){
Image.GrayScale().DOM(document.body);
}
});
new ImageManipulation({
image: image,
callback: function(Image){
Image.Desaturate(50).DOM(document.body);
}
});
new ImageManipulation({
image: image,
callback: function(Image){
Image.Channel('r').DOM(document.body);
}
});

• grayscale is one word, there's no need for snakeCase. Dec 2, 2015 at 0:35

Miscellaneous Points:

• As you're writing a library, it's best to match as many edge cases as you can, and to check if document even exists.

if (!document) throw new Error("the document could not be found");

• Instead of using just throw use throw new Error() instead, as that allows for a stack trace to find exactly where the problem stems.

• The following can be converted to a one-liner:

    if(!this.src)
throw('Initialisation failed: no image in options.');

• Single line comments should be written with // instead of /* ... */:
/* Canvas for input. */

• The following block should be removed, instead of a large block like that, you should just have a quick paragraph or even sentence about what the function is:
/***********************************************************/
/********************** Core Functions *********************/
/***********************************************************/


into something like:

/* This part of the library houses the core functions,
* ... more stuff here ...
*/


- The following can be converted to a ternary statement, and it's also missing space:

        if(addLine < id-1 || addLine > id+1){
return false;
} else {
}


into:

return (addLine < id - 1 || addLine > id + 1) ? false : add;

• You need to add spaces between your operators, like in the snippet below:
angle = angle%360;


Additionally, there is a %= operator, so it can become this:

angle %= 360

• In the following code block, there's two points to make:
    getPixelAtAxisFromPixel: function(id, angle){
Self = this.Self || {width: 0};

1. You're redefining angle (literally pointless)
2. You don't need to define Self with the others, define it on the second line.

• You should be using a switch here, instead of an if block, which would be incorrectly formatted anyway (you should've used else-ifs as channel could only take one of those forms:
    if(channel == 'r') channel = 0;
if(channel == 'g') channel = 1;
if(channel == 'b') channel = 2;
if(channel == 'a') channel = 3;


into:

switch(channel){
case 'r': channel = 0; break;
case 'g': channel = 1; break;
case 'b': channel = 2; break;
case 'a': channel = 3; break;
}


or, another fun way is to use an object, like so:

var channelSwap = {r: 0, g: 1, b: 2, a: 3};
channel = channelSwap[channel];

• forHumans uses different values for human perception. this is confusing, but not nearly as much as the following:
    forHumans = forHumans
? [.3,.4,.3]
: [(1/3),(1/3),(1/3)];

• As all three statements end the block, the last else case is superfluous:
    if(!this.ready){
if(callee){
}
return false;
} else {
return this;
}


the same applies to the following:

    if(element){
element.appendChild(this.output);
return true;
} else {
return this.output;
}

• For the work you've put into it, consider giving it a sleek name, instead of ImageManipulation

ImageManipulation.prototype = {
init: function(){},
...
Support: function(){}
}


ImageManipulation.prototype.init = function(){};
...
ImageManipulation.prototype.Support = function(){};


You're currently indenting all your declarations with a large amount of space. Stop it. By following my last point, you can avoid that kind of behaviour all together, but really, it's horrible.

And sometimes you mess it up.

    var alpha                = channel == 3 ? this.source.data[i+3]     : 0;
this.resource.data[i]     = channel == 0 ? this.source.data[i]         : alpha;
this.resource.data[i+1] = channel == 1 ? this.source.data[i+1]     : alpha;
this.resource.data[i+2] = channel == 2 ? this.source.data[i+2]     : alpha;
this.resource.data[i+3] = 255;


I would consider taking a look at Caman.JS, a image manipulation library with similar features to the one you've made.

• These are awesome comments, thanks! (I was kind of thinking nobody was ever going to reply :) ). With a different project I have recently learned that it is more efficient to repeat the prototype declaration (if only because collapsing the folds gives you a nice overview), so its nice to see that verified here! The indenting is because of the difference between tabs and spaces in different applications, but it is true - there is way too much of it here. Great stuff, I will be looking into correcting it soon! Cheers Dec 2, 2015 at 15:44