smartcrop
Version:
Content aware image cropping.
595 lines (536 loc) • 17.7 kB
JavaScript
/**
* smartcrop.js
* A javascript library implementing content aware image cropping
*
* Copyright (C) 2018 Jonas Wagner
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
(function() {
'use strict';
var smartcrop = {};
// Promise implementation to use
function NoPromises() {
throw new Error('No native promises and smartcrop.Promise not set.');
}
smartcrop.Promise = typeof Promise !== 'undefined' ? Promise : NoPromises;
smartcrop.DEFAULTS = {
width: 0,
height: 0,
aspect: 0,
cropWidth: 0,
cropHeight: 0,
detailWeight: 0.2,
skinColor: [0.78, 0.57, 0.44],
skinBias: 0.01,
skinBrightnessMin: 0.2,
skinBrightnessMax: 1.0,
skinThreshold: 0.8,
skinWeight: 1.8,
saturationBrightnessMin: 0.05,
saturationBrightnessMax: 0.9,
saturationThreshold: 0.4,
saturationBias: 0.2,
saturationWeight: 0.1,
// Step * minscale rounded down to the next power of two should be good
scoreDownSample: 8,
step: 8,
scaleStep: 0.1,
minScale: 1.0,
maxScale: 1.0,
edgeRadius: 0.4,
edgeWeight: -20.0,
outsideImportance: -0.5,
boostWeight: 100.0,
ruleOfThirds: true,
prescale: true,
imageOperations: null,
canvasFactory: defaultCanvasFactory,
// Factory: defaultFactories,
debug: false
};
smartcrop.crop = function(inputImage, options_, callback) {
var options = extend({}, smartcrop.DEFAULTS, options_);
if (options.aspect) {
options.width = options.aspect;
options.height = 1;
}
if (options.imageOperations === null) {
options.imageOperations = canvasImageOperations(options.canvasFactory);
}
var iop = options.imageOperations;
var scale = 1;
var prescale = 1;
// open the image
return iop
.open(inputImage, options.input)
.then(function(image) {
// calculate desired crop dimensions based on the image size
if (options.width && options.height) {
scale = min(
image.width / options.width,
image.height / options.height
);
options.cropWidth = ~~(options.width * scale);
options.cropHeight = ~~(options.height * scale);
// Img = 100x100, width = 95x95, scale = 100/95, 1/scale > min
// don't set minscale smaller than 1/scale
// -> don't pick crops that need upscaling
options.minScale = min(
options.maxScale,
max(1 / scale, options.minScale)
);
// prescale if possible
if (options.prescale !== false) {
prescale = min(max(256 / image.width, 256 / image.height), 1);
if (prescale < 1) {
image = iop.resample(
image,
image.width * prescale,
image.height * prescale
);
options.cropWidth = ~~(options.cropWidth * prescale);
options.cropHeight = ~~(options.cropHeight * prescale);
if (options.boost) {
options.boost = options.boost.map(function(boost) {
return {
x: ~~(boost.x * prescale),
y: ~~(boost.y * prescale),
width: ~~(boost.width * prescale),
height: ~~(boost.height * prescale),
weight: boost.weight
};
});
}
} else {
prescale = 1;
}
}
}
return image;
})
.then(function(image) {
return iop.getData(image).then(function(data) {
var result = analyse(options, data);
var crops = result.crops || [result.topCrop];
for (var i = 0, iLen = crops.length; i < iLen; i++) {
var crop = crops[i];
crop.x = ~~(crop.x / prescale);
crop.y = ~~(crop.y / prescale);
crop.width = ~~(crop.width / prescale);
crop.height = ~~(crop.height / prescale);
}
if (callback) callback(result);
return result;
});
});
};
// Check if all the dependencies are there
// todo:
smartcrop.isAvailable = function(options) {
if (!smartcrop.Promise) return false;
var canvasFactory = options ? options.canvasFactory : defaultCanvasFactory;
if (canvasFactory === defaultCanvasFactory) {
var c = document.createElement('canvas');
if (!c.getContext('2d')) {
return false;
}
}
return true;
};
function edgeDetect(i, o) {
var id = i.data;
var od = o.data;
var w = i.width;
var h = i.height;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var p = (y * w + x) * 4;
var lightness;
if (x === 0 || x >= w - 1 || y === 0 || y >= h - 1) {
lightness = sample(id, p);
} else {
lightness =
sample(id, p) * 4 -
sample(id, p - w * 4) -
sample(id, p - 4) -
sample(id, p + 4) -
sample(id, p + w * 4);
}
od[p + 1] = lightness;
}
}
}
function skinDetect(options, i, o) {
var id = i.data;
var od = o.data;
var w = i.width;
var h = i.height;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var p = (y * w + x) * 4;
var lightness = cie(id[p], id[p + 1], id[p + 2]) / 255;
var skin = skinColor(options, id[p], id[p + 1], id[p + 2]);
var isSkinColor = skin > options.skinThreshold;
var isSkinBrightness =
lightness >= options.skinBrightnessMin &&
lightness <= options.skinBrightnessMax;
if (isSkinColor && isSkinBrightness) {
od[p] =
(skin - options.skinThreshold) *
(255 / (1 - options.skinThreshold));
} else {
od[p] = 0;
}
}
}
}
function saturationDetect(options, i, o) {
var id = i.data;
var od = o.data;
var w = i.width;
var h = i.height;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var p = (y * w + x) * 4;
var lightness = cie(id[p], id[p + 1], id[p + 2]) / 255;
var sat = saturation(id[p], id[p + 1], id[p + 2]);
var acceptableSaturation = sat > options.saturationThreshold;
var acceptableLightness =
lightness >= options.saturationBrightnessMin &&
lightness <= options.saturationBrightnessMax;
if (acceptableLightness && acceptableSaturation) {
od[p + 2] =
(sat - options.saturationThreshold) *
(255 / (1 - options.saturationThreshold));
} else {
od[p + 2] = 0;
}
}
}
}
function applyBoosts(options, output) {
if (!options.boost) return;
var od = output.data;
for (var i = 0; i < output.width; i += 4) {
od[i + 3] = 0;
}
for (i = 0; i < options.boost.length; i++) {
applyBoost(options.boost[i], options, output);
}
}
function applyBoost(boost, options, output) {
var od = output.data;
var w = output.width;
var x0 = ~~boost.x;
var x1 = ~~(boost.x + boost.width);
var y0 = ~~boost.y;
var y1 = ~~(boost.y + boost.height);
var weight = boost.weight * 255;
for (var y = y0; y < y1; y++) {
for (var x = x0; x < x1; x++) {
var i = (y * w + x) * 4;
od[i + 3] += weight;
}
}
}
function generateCrops(options, width, height) {
var results = [];
var minDimension = min(width, height);
var cropWidth = options.cropWidth || minDimension;
var cropHeight = options.cropHeight || minDimension;
for (
var scale = options.maxScale;
scale >= options.minScale;
scale -= options.scaleStep
) {
for (var y = 0; y + cropHeight * scale <= height; y += options.step) {
for (var x = 0; x + cropWidth * scale <= width; x += options.step) {
results.push({
x: x,
y: y,
width: cropWidth * scale,
height: cropHeight * scale
});
}
}
}
return results;
}
function score(options, output, crop) {
var result = {
detail: 0,
saturation: 0,
skin: 0,
boost: 0,
total: 0
};
var od = output.data;
var downSample = options.scoreDownSample;
var invDownSample = 1 / downSample;
var outputHeightDownSample = output.height * downSample;
var outputWidthDownSample = output.width * downSample;
var outputWidth = output.width;
for (var y = 0; y < outputHeightDownSample; y += downSample) {
for (var x = 0; x < outputWidthDownSample; x += downSample) {
var p =
(~~(y * invDownSample) * outputWidth + ~~(x * invDownSample)) * 4;
var i = importance(options, crop, x, y);
var detail = od[p + 1] / 255;
result.skin += (od[p] / 255) * (detail + options.skinBias) * i;
result.detail += detail * i;
result.saturation +=
(od[p + 2] / 255) * (detail + options.saturationBias) * i;
result.boost += (od[p + 3] / 255) * i;
}
}
result.total =
(result.detail * options.detailWeight +
result.skin * options.skinWeight +
result.saturation * options.saturationWeight +
result.boost * options.boostWeight) /
(crop.width * crop.height);
return result;
}
function importance(options, crop, x, y) {
if (
crop.x > x ||
x >= crop.x + crop.width ||
crop.y > y ||
y >= crop.y + crop.height
) {
return options.outsideImportance;
}
x = (x - crop.x) / crop.width;
y = (y - crop.y) / crop.height;
var px = abs(0.5 - x) * 2;
var py = abs(0.5 - y) * 2;
// Distance from edge
var dx = Math.max(px - 1.0 + options.edgeRadius, 0);
var dy = Math.max(py - 1.0 + options.edgeRadius, 0);
var d = (dx * dx + dy * dy) * options.edgeWeight;
var s = 1.41 - sqrt(px * px + py * py);
if (options.ruleOfThirds) {
s += Math.max(0, s + d + 0.5) * 1.2 * (thirds(px) + thirds(py));
}
return s + d;
}
smartcrop.importance = importance;
function skinColor(options, r, g, b) {
var mag = sqrt(r * r + g * g + b * b);
var rd = r / mag - options.skinColor[0];
var gd = g / mag - options.skinColor[1];
var bd = b / mag - options.skinColor[2];
var d = sqrt(rd * rd + gd * gd + bd * bd);
return 1 - d;
}
function analyse(options, input) {
var result = {};
var output = new ImgData(input.width, input.height);
edgeDetect(input, output);
skinDetect(options, input, output);
saturationDetect(options, input, output);
applyBoosts(options, output);
var scoreOutput = downSample(output, options.scoreDownSample);
var topScore = -Infinity;
var topCrop = null;
var crops = generateCrops(options, input.width, input.height);
for (var i = 0, iLen = crops.length; i < iLen; i++) {
var crop = crops[i];
crop.score = score(options, scoreOutput, crop);
if (crop.score.total > topScore) {
topCrop = crop;
topScore = crop.score.total;
}
}
result.topCrop = topCrop;
if (options.debug && topCrop) {
result.crops = crops;
result.debugOutput = output;
result.debugOptions = options;
// Create a copy which will not be adjusted by the post scaling of smartcrop.crop
result.debugTopCrop = extend({}, result.topCrop);
}
return result;
}
function ImgData(width, height, data) {
this.width = width;
this.height = height;
if (data) {
this.data = new Uint8ClampedArray(data);
} else {
this.data = new Uint8ClampedArray(width * height * 4);
}
}
smartcrop.ImgData = ImgData;
function downSample(input, factor) {
var idata = input.data;
var iwidth = input.width;
var width = Math.floor(input.width / factor);
var height = Math.floor(input.height / factor);
var output = new ImgData(width, height);
var data = output.data;
var ifactor2 = 1 / (factor * factor);
for (var y = 0; y < height; y++) {
for (var x = 0; x < width; x++) {
var i = (y * width + x) * 4;
var r = 0;
var g = 0;
var b = 0;
var a = 0;
var mr = 0;
var mg = 0;
for (var v = 0; v < factor; v++) {
for (var u = 0; u < factor; u++) {
var j = ((y * factor + v) * iwidth + (x * factor + u)) * 4;
r += idata[j];
g += idata[j + 1];
b += idata[j + 2];
a += idata[j + 3];
mr = Math.max(mr, idata[j]);
mg = Math.max(mg, idata[j + 1]);
// unused
// mb = Math.max(mb, idata[j + 2]);
}
}
// this is some funky magic to preserve detail a bit more for
// skin (r) and detail (g). Saturation (b) does not get this boost.
data[i] = r * ifactor2 * 0.5 + mr * 0.5;
data[i + 1] = g * ifactor2 * 0.7 + mg * 0.3;
data[i + 2] = b * ifactor2;
data[i + 3] = a * ifactor2;
}
}
return output;
}
smartcrop._downSample = downSample;
function defaultCanvasFactory(w, h) {
var c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
function canvasImageOperations(canvasFactory) {
return {
// Takes imageInput as argument
// returns an object which has at least
// {width: n, height: n}
open: function(image) {
// Work around images scaled in css by drawing them onto a canvas
var w = image.naturalWidth || image.width;
var h = image.naturalHeight || image.height;
var c = canvasFactory(w, h);
var ctx = c.getContext('2d');
if (
image.naturalWidth &&
(image.naturalWidth != image.width ||
image.naturalHeight != image.height)
) {
c.width = image.naturalWidth;
c.height = image.naturalHeight;
} else {
c.width = image.width;
c.height = image.height;
}
ctx.drawImage(image, 0, 0);
return smartcrop.Promise.resolve(c);
},
// Takes an image (as returned by open), and changes it's size by resampling
resample: function(image, width, height) {
return Promise.resolve(image).then(function(image) {
var c = canvasFactory(~~width, ~~height);
var ctx = c.getContext('2d');
ctx.drawImage(
image,
0,
0,
image.width,
image.height,
0,
0,
c.width,
c.height
);
return smartcrop.Promise.resolve(c);
});
},
getData: function(image) {
return Promise.resolve(image).then(function(c) {
var ctx = c.getContext('2d');
var id = ctx.getImageData(0, 0, c.width, c.height);
return new ImgData(c.width, c.height, id.data);
});
}
};
}
smartcrop._canvasImageOperations = canvasImageOperations;
// Aliases and helpers
var min = Math.min;
var max = Math.max;
var abs = Math.abs;
var sqrt = Math.sqrt;
function extend(o) {
for (var i = 1, iLen = arguments.length; i < iLen; i++) {
var arg = arguments[i];
if (arg) {
for (var name in arg) {
o[name] = arg[name];
}
}
}
return o;
}
// Gets value in the range of [0, 1] where 0 is the center of the pictures
// returns weight of rule of thirds [0, 1]
function thirds(x) {
x = (((x - 1 / 3 + 1.0) % 2.0) * 0.5 - 0.5) * 16;
return Math.max(1.0 - x * x, 0.0);
}
function cie(r, g, b) {
return 0.5126 * b + 0.7152 * g + 0.0722 * r;
}
function sample(id, p) {
return cie(id[p], id[p + 1], id[p + 2]);
}
function saturation(r, g, b) {
var maximum = max(r / 255, g / 255, b / 255);
var minimum = min(r / 255, g / 255, b / 255);
if (maximum === minimum) {
return 0;
}
var l = (maximum + minimum) / 2;
var d = maximum - minimum;
return l > 0.5 ? d / (2 - maximum - minimum) : d / (maximum + minimum);
}
// Amd
if (typeof define !== 'undefined' && define.amd)
define(function() {
return smartcrop;
});
// Common js
if (typeof exports !== 'undefined') exports.smartcrop = smartcrop;
else if (typeof navigator !== 'undefined')
// Browser
window.SmartCrop = window.smartcrop = smartcrop;
// Nodejs
if (typeof module !== 'undefined') {
module.exports = smartcrop;
}
})();