Photo Adjustments with Martin.js

July 20, 2015

This post last updated August 19, 2015, to reflect changes from Martin.js v0.3.1.


With Martin.js officially out of beta, I thought I’d show off some of its niftier capabilities. Martin.js can do basic photo editing, like you would do in Photoshop, but with a few lines of code, you can also manipulate photos interactively, and animate, all in browser*.

*Of course, for best results, view in Chrome.

Adjust the levels:

← Dark | Light →
← Black & White | Vibrant →

In the above example, I’m just calling two built-in methods — .lighten() and .saturate() and adjusting the amount based on the slider. These methods create Effects that are applied to a canvas layer and can be stacked on top of each other. Read more about Effects in Martin.js.

In addition to the built-in Effects, it’s relatively easy to create your own — it just takes a little knowledge of how pixel data is displayed on the screen. Each pixel contains four values that describe how it’s displayed: red, green, blue, and alpha (transparency).

By looping through all the pixels in a photo (which sounds overwhelming, but can actually be done relatively quickly), you can not only retrieve the rgba values, but also manipulate them. Martin.js comes with an easy way to do this — the .loop() method.

layer.loop(function(x, y, pixel) {
    // x and y are the pixel's x and y coordinates (from top left),
    // pixel is an object with four keys: r, g, b, a

    // IMPORTANT! Always end with `return pixel;` to save changes
    return pixel;
});

A trivial but impactful example would be to overwrite the pixel’s red value to be 255 (the maximum, on a scale of 0-255). To do this, we first have to register this as a new effect, and then apply it to our canvas.

Martin.registerEffect('redden', function() {

    // inside the callback function, this.context refers
    // to the layer or element the effect was called on
    this.context.loop(function(x, y, pixel) {
        pixel.r = 255;
        return pixel;
    });
});

canvas.redden();

An effect can also be applied interactively when the user mouses over the photo, creating a spotlight.

Martin.registerEffect('spotlight', function(coords) {
    this.context.loop(function(x, y, pixel) {

        // Get the distance between the cursor
        // and the current pixel we're looping over.
        var distance;
        xDiff = coords.x - x;
        yDiff = coords.y - y;
        distance = Math.sqrt(xDiff * xDiff + yDiff * yDiff);

        // Finesse the number so it doesn't act so strongly.
        distance *= 0.3;

        // Subtract it from the pixel's r, g, and b values.
        // We don't have to worry about it going negative ---
        // it will automatically clamp between 0 and 255.
        pixel.r -= distance;
        pixel.g -= distance;
        pixel.b -= distance;

        return pixel;
    });
});

The key in the above example is to remove and re-apply the spotlight with each mousemove. Otherwise, the Effect will act recursively, quickly turning every pixel black. Read more about Events in Martin.js.

// Placeholder for the effect, outside the scope of the event callback.
var spotlight;

canvas.mousemove(function(e) {

    // Remove the effect so it doesn't compound recursively.
    if ( spotlight ) spotlight.remove();

    // Re-apply the effect, passing in the mouse coordinates.
    spotlight = canvas.spotlight({
        x: e.x,
        y: e.y
    });
});

A number of effects from Photoshop or other photo editing programs can easily be recreated, for example the gradient map.

A gradient map will clamp outlying color values to a predetermined range. If your map is from blue to yellow, then dark values will appear blue, light values will appear yellow, and anything between will be coerced to that spectrum (that is, greenish, as in the above example).

canvas.gradientMap({
    start: '#00f',
    end: '#ff0'
});

A gradient map can also invert a photo, mapping dark to light and vice-versa. And while it can completely invert the image, as in the immediate above example, it can also invert some values but not others, as in the example above that, where red is inverted but blue and green are left untouched.

Martin.registerEffect('gradientMap', function(data) {

    var min = parseHex(data.start),
        max = parseHex(data.end);

    // Need a parseHex() function to get r/g/b values
    // from a hex string.
    function parseHex(hex) {

        var output;

        if ( hex.charAt(0) === '#' ) hex = hex.slice(1);

        // Create a six-digit hex if only three are given.
        if ( hex.length === 3 ) {
            hex = hex.split('');
            hex.splice(2, 0, hex[2]);
            hex.splice(1, 0, hex[1]);
            hex.splice(0, 0, hex[0]);
            hex = hex.join('');
        }

        output = {
            r: parseInt(hex[0] + hex[1], 16),
            g: parseInt(hex[2] + hex[3], 16),
            b: parseInt(hex[4] + hex[5], 16)
        };

        return output;
    }

    this.context.loop(function(x, y, pixel) {
        // For red, green, and blue, add to the minimum value (from
        // the parseHex() function) the mapped value of this pixel onto
        // the given map range.
        pixel.r = Math.round(min.r + (pixel.r / 256) * (max.r - min.r));
        pixel.g = Math.round(min.g + (pixel.g / 256) * (max.g - min.g));
        pixel.b = Math.round(min.b + (pixel.b / 256) * (max.b - min.b));

        return pixel;
    });
});

Effects can change based on data other than user interaction and existing pixel data, such as time. By using requestAnimationFrame(), we can easily make animations from photos.

In the above example, a new .rainbow() Effect takes a time parameter, which is incremented with each available animation frame. It loops and increases the pixel’s red, green, and blue values, lightening them. The amount it lightens each value depends on the pixel’s x value (in any pixel column, they are all being lightened by the same amount) and on the time. However, red, green, and blue all act differently based on the time parameter — the blue “wave” is faster than green, which is faster than red — causing the rainbow-like color shifts we see.

// Similar to the spotlight example, we need a placeholder
// for our effect outside the scope of the function.
var rainbow,
    t = 0; // time starts at 0

Martin.registerEffect('rainbow', function(t) {
    this.context.loop(function(x, y, pixel) {

        // Increase by a maximum of 100, and take in x value
        // and time as parameters.
        pixel.r += 100 * Math.sin((x - 4 * t) / 100);
        pixel.g += 100 * Math.sin((x - 8 * t) / 100);
        pixel.b += 100 * Math.sin((x - 12 * t) / 100);

        return pixel;
    });
});

// This function will automatically invoke, and by passing itself into
// requestAnimationFrame, will be continuously invoked with each
// available animation frame.
(function createRainbow() {

    if ( rainbow ) rainbow.remove();
    rainbow = canvas.rainbow(t);
    t++;

    // requestAnimationFrame will wait until the browser is ready to
    // repaint the canvas.
    requestAnimationFrame(createRainbow);
})();

These are just a few examples of what can be done with Martin.js. Check out the project on GitHub, and if you make anything cool, let me know on Twitter!