Files
30-seconds-of-code/node_modules/potrace/lib/Posterizer.js
2019-08-20 15:52:05 +02:00

450 lines
14 KiB
JavaScript

'use strict';
var Potrace = require('./Potrace');
var utils = require('./utils');
/**
* Takes multiple samples using {@link Potrace} with different threshold
* settings and combines output into a single file.
*
* @param {Posterizer~Options} [options]
* @constructor
*/
function Posterizer(options) {
this._potrace = new Potrace();
this._calculatedThreshold = null;
this._params = {
threshold: Potrace.THRESHOLD_AUTO,
blackOnWhite: true,
steps: Posterizer.STEPS_AUTO,
background: Potrace.COLOR_TRANSPARENT,
fillStrategy: Posterizer.FILL_DOMINANT,
rangeDistribution: Posterizer.RANGES_AUTO
};
if (options) {
this.setParameters(options);
}
}
// Inherit constants from Potrace class
for (var key in Potrace) {
if (Object.prototype.hasOwnProperty.call(Potrace, key) && key === key.toUpperCase()) {
Posterizer[key] = Potrace[key];
}
}
Posterizer.STEPS_AUTO = -1;
Posterizer.FILL_SPREAD = 'spread';
Posterizer.FILL_DOMINANT = 'dominant';
Posterizer.FILL_MEDIAN = 'median';
Posterizer.FILL_MEAN = 'mean';
Posterizer.RANGES_AUTO = 'auto';
Posterizer.RANGES_EQUAL = 'equal';
Posterizer.prototype = {
/**
* Fine tuning to color ranges.
*
* If last range (featuring most saturated color) is larger than 10% of color space (25 units)
* then we want to add another color stop, that hopefully will include darkest pixels, improving presence of
* shadows and line art
*
* @param ranges
* @private
*/
_addExtraColorStop: function(ranges) {
var blackOnWhite = this._params.blackOnWhite;
var lastColorStop = ranges[ranges.length - 1];
var lastRangeFrom = blackOnWhite ? 0 : lastColorStop.value;
var lastRangeTo = blackOnWhite ? lastColorStop.value : 255;
if (lastRangeTo - lastRangeFrom > 25 && lastColorStop.colorIntensity !== 1) {
var histogram = this._getImageHistogram();
var levels = histogram.getStats(lastRangeFrom, lastRangeTo).levels;
var newColorStop = levels.mean + levels.stdDev <= 25 ? levels.mean + levels.stdDev
: levels.mean - levels.stdDev <= 25 ? levels.mean - levels.stdDev
: 25;
var newStats = (blackOnWhite ? histogram.getStats(0, newColorStop) : histogram.getStats(newColorStop, 255));
var color = newStats.levels.mean;
ranges.push({
value: Math.abs((blackOnWhite ? 0 : 255) - newColorStop),
colorIntensity: isNaN(color) ? 0 : ((blackOnWhite ? 255 - color : color) / 255)
});
}
return ranges;
},
/**
* Calculates color intensity for each element of numeric array
*
* @param {number[]} colorStops
* @returns {{ levels: number, colorIntensity: number }[]}
* @private
*/
_calcColorIntensity: function(colorStops) {
var blackOnWhite = this._params.blackOnWhite;
var colorSelectionStrat = this._params.fillStrategy;
var histogram = colorSelectionStrat !== Posterizer.FILL_SPREAD ? this._getImageHistogram() : null;
var fullRange = Math.abs(this._paramThreshold() - (blackOnWhite ? 0 : 255));
return colorStops.map(function(threshold, index) {
var nextValue = index + 1 === colorStops.length ? (blackOnWhite ? -1 : 256) : colorStops[index + 1];
var rangeStart = Math.round(blackOnWhite ? nextValue + 1 : threshold);
var rangeEnd = Math.round(blackOnWhite ? threshold : nextValue - 1);
var factor = index / (colorStops.length - 1);
var intervalSize = rangeEnd - rangeStart;
var stats = histogram.getStats(rangeStart, rangeEnd);
var color = -1;
if (stats.pixels === 0) {
return {
value: threshold,
colorIntensity: 0
};
}
switch (colorSelectionStrat) {
case Posterizer.FILL_SPREAD:
// We want it to be 0 (255 when white on black) at the most saturated end, so...
color = (blackOnWhite ? rangeStart : rangeEnd)
+ (blackOnWhite ? 1 : -1) * intervalSize * Math.max(0.5, fullRange / 255) * factor;
break;
case Posterizer.FILL_DOMINANT:
color = histogram.getDominantColor(rangeStart, rangeEnd, utils.clamp(intervalSize, 1, 5));
break;
case Posterizer.FILL_MEAN:
color = stats.levels.mean;
break;
case Posterizer.FILL_MEDIAN:
color = stats.levels.median;
break;
}
// We don't want colors to be too close to each other, so we introduce some spacing in between
if (index !== 0) {
color = blackOnWhite
? utils.clamp(color, rangeStart, rangeEnd - Math.round(intervalSize * 0.1))
: utils.clamp(color, rangeStart + Math.round(intervalSize * 0.1), rangeEnd);
}
return {
value: threshold,
colorIntensity: color === -1 ? 0 : ((blackOnWhite ? 255 - color : color) / 255)
};
});
},
/**
* @returns {Histogram}
* @private
*/
_getImageHistogram: function() {
return this._potrace._luminanceData.histogram();
},
/**
* Processes threshold, steps and rangeDistribution parameters and returns normalized array of color stops
* @returns {*}
* @private
*/
_getRanges: function() {
var steps = this._paramSteps();
if (!Array.isArray(steps)) {
return this._params.rangeDistribution === Posterizer.RANGES_AUTO
? this._getRangesAuto()
: this._getRangesEquallyDistributed();
}
// Steps is array of thresholds and we want to preprocess it
var colorStops = [];
var threshold = this._paramThreshold();
var lookingForDarkPixels = this._params.blackOnWhite;
steps.forEach(function(item) {
if (colorStops.indexOf(item) === -1 && utils.between(item, 0, 255)) {
colorStops.push(item);
}
});
if (!colorStops.length) {
colorStops.push(threshold);
}
colorStops = colorStops.sort(function (a, b) {
return a < b === lookingForDarkPixels ? 1 : -1;
});
if (lookingForDarkPixels && colorStops[0] < threshold) {
colorStops.unshift(threshold);
} else if (!lookingForDarkPixels && colorStops[colorStops.length - 1] < threshold) {
colorStops.push(threshold);
}
return this._calcColorIntensity(colorStops);
},
/**
* Calculates given (or lower) number of thresholds using automatic thresholding algorithm
* @returns {*}
* @private
*/
_getRangesAuto: function() {
var histogram = this._getImageHistogram();
var steps = this._paramSteps(true);
var colorStops;
if (this._params.threshold === Potrace.THRESHOLD_AUTO) {
colorStops = histogram.multilevelThresholding(steps);
} else {
var threshold = this._paramThreshold();
colorStops = this._params.blackOnWhite
? histogram.multilevelThresholding(steps - 1, 0, threshold)
: histogram.multilevelThresholding(steps - 1, threshold, 255);
if (this._params.blackOnWhite) {
colorStops.push(threshold);
} else {
colorStops.unshift(threshold);
}
}
if (this._params.blackOnWhite) {
colorStops = colorStops.reverse();
}
return this._calcColorIntensity(colorStops);
},
/**
* Calculates color stops and color representing each segment, returning them
* from least to most intense color (black or white, depending on blackOnWhite parameter)
*
* @private
*/
_getRangesEquallyDistributed: function() {
var blackOnWhite = this._params.blackOnWhite;
var colorsToThreshold = blackOnWhite ? this._paramThreshold() : 255 - this._paramThreshold();
var steps = this._paramSteps();
var stepSize = colorsToThreshold / steps;
var colorStops = [];
var i = steps - 1,
factor,
threshold;
while (i >= 0) {
factor = i / (steps - 1);
threshold = Math.min(colorsToThreshold, (i + 1) * stepSize);
threshold = blackOnWhite ? threshold : 255 - threshold;
i--;
colorStops.push(threshold);
}
return this._calcColorIntensity(colorStops);
},
/**
* Returns valid steps value
* @param {Boolean} [count=false]
* @returns {number|number[]}
* @private
*/
_paramSteps: function(count) {
var steps = this._params.steps;
if (Array.isArray(steps)) {
return count ? steps.length : steps;
}
if (steps === Posterizer.STEPS_AUTO && this._params.threshold === Potrace.THRESHOLD_AUTO) {
return 4;
}
var blackOnWhite = this._params.blackOnWhite;
var colorsCount = blackOnWhite ? this._paramThreshold() : 255 - this._paramThreshold();
return steps === Posterizer.STEPS_AUTO
? (colorsCount > 200 ? 4 : 3)
: Math.min(colorsCount, Math.max(2, steps));
},
/**
* Returns valid threshold value
* @returns {number}
* @private
*/
_paramThreshold: function() {
if (this._calculatedThreshold !== null) {
return this._calculatedThreshold;
}
if (this._params.threshold !== Potrace.THRESHOLD_AUTO) {
this._calculatedThreshold = this._params.threshold;
return this._calculatedThreshold;
}
var twoThresholds = this._getImageHistogram().multilevelThresholding(2);
this._calculatedThreshold = this._params.blackOnWhite ? twoThresholds[1] : twoThresholds[0];
this._calculatedThreshold = this._calculatedThreshold || 128;
return this._calculatedThreshold;
},
/**
* Running potrace on the image multiple times with different thresholds and returns an array
* of path tags
*
* @param {Boolean} [noFillColor]
* @returns {string[]}
* @private
*/
_pathTags: function(noFillColor) {
var ranges = this._getRanges();
var potrace = this._potrace;
var blackOnWhite = this._params.blackOnWhite;
if (ranges.length >= 10) {
ranges = this._addExtraColorStop(ranges);
}
potrace.setParameters({ blackOnWhite: blackOnWhite });
var actualPrevLayersOpacity = 0;
return ranges.map(function(colorStop) {
var thisLayerOpacity = colorStop.colorIntensity;
if (thisLayerOpacity === 0) {
return '';
}
// NOTE: With big number of layers (something like 70) there will be noticeable math error on rendering side.
// In Chromium at least image will end up looking brighter overall compared to the same layers painted in solid colors.
// However it works fine with sane number of layers, and it's not like we can do much about it.
var calculatedOpacity = (!actualPrevLayersOpacity || thisLayerOpacity === 1)
? thisLayerOpacity
: ((actualPrevLayersOpacity - thisLayerOpacity) / (actualPrevLayersOpacity - 1));
calculatedOpacity = utils.clamp(parseFloat(calculatedOpacity.toFixed(3)), 0, 1);
actualPrevLayersOpacity = actualPrevLayersOpacity + (1 - actualPrevLayersOpacity) * calculatedOpacity;
potrace.setParameters({ threshold: colorStop.value });
var element = noFillColor ? potrace.getPathTag('') : potrace.getPathTag();
element = utils.setHtmlAttr(element, 'fill-opacity', calculatedOpacity.toFixed(3));
var canBeIgnored = calculatedOpacity === 0 || element.indexOf(' d=""') !== -1;
// var c = Math.round(Math.abs((blackOnWhite ? 255 : 0) - 255 * thisLayerOpacity));
// element = utils.setHtmlAttr(element, 'fill', 'rgb('+c+', '+c+', '+c+')');
// element = utils.setHtmlAttr(element, 'fill-opacity', '');
return canBeIgnored ? '' : element;
});
},
/**
* Loads image.
*
* @param {string|Buffer|Jimp} target Image source. Could be anything that {@link Jimp} can read (buffer, local path or url). Supported formats are: PNG, JPEG or BMP
* @param {Function} callback
*/
loadImage: function(target, callback) {
var self = this;
this._potrace.loadImage(target, function(err) {
self._calculatedThreshold = null;
callback.call(self, err);
});
},
/**
* Sets parameters. Accepts same object as {Potrace}
*
* @param {Posterizer~Options} params
*/
setParameters: function(params) {
if (!params) {
return;
}
this._potrace.setParameters(params);
if (params.steps && !Array.isArray(params.steps) && (!utils.isNumber(params.steps) || !utils.between(params.steps, 1, 255))) {
throw new Error('Bad \'steps\' value');
}
for (var key in this._params) {
if (this._params.hasOwnProperty(key) && params.hasOwnProperty(key)) {
this._params[key] = params[key];
}
}
this._calculatedThreshold = null;
},
/**
* Returns image as <symbol> tag. Always has viewBox specified
*
* @param {string} id
*/
getSymbol: function(id) {
var width = this._potrace._luminanceData.width;
var height = this._potrace._luminanceData.height;
var paths = this._pathTags(true);
return '<symbol viewBox="0 0 ' + width + ' ' + height + '" id="' + id + '">' +
paths.join('') +
'</symbol>';
},
/**
* Generates SVG image
* @returns {String}
*/
getSVG: function() {
var width = this._potrace._luminanceData.width,
height = this._potrace._luminanceData.height;
var tags = this._pathTags(false);
var svg = '<svg xmlns="http://www.w3.org/2000/svg" ' +
'width="' + width + '" ' +
'height="' + height + '" ' +
'viewBox="0 0 ' + width + ' ' + height + '" ' +
'version="1.1">\n\t' +
(this._params.background !== Potrace.COLOR_TRANSPARENT
? '<rect x="0" y="0" width="100%" height="100%" fill="' + this._params.background + '" />\n\t'
: '') +
tags.join('\n\t') +
'\n</svg>';
return svg.replace(/\n(?:\t*\n)+(\t*)/g, '\n$1');
}
};
module.exports = Posterizer;
/**
* Posterizer options
*
* @typedef {Potrace~Options} Posterizer~Options
* @property {Number} [steps] - Number of samples that needs to be taken (and number of layers in SVG). (default: Posterizer.STEPS_AUTO, which most likely will result in 3, sometimes 4)
* @property {*} [fillStrategy] - How to select fill color for color ranges - equally spread or dominant. (default: Posterizer.FILL_DOMINANT)
* @property {*} [rangeDistribution] - How to choose thresholds in-between - after equal intervals or automatically balanced. (default: Posterizer.RANGES_AUTO)
*/