"use strict";
/**
* Creates new instance of Image.
*
* @class
* @name Image
* @arg {string} srcPath - path to processed image
* @arg {object} [srcOpts] - image options
* @arg {number} [srcOpts.scaleX=1] - image current scale value on `X` axis
* @arg {number} [srcOpts.scaleY=1] - image current scale value on `Y` axis
* @arg {object} [injects] - Dependency injections.
* @arg {object} [injects.sharp] - Injected `sharp` module.
*/
var fs = require("fs");
var _ = require("lodash");
var pixelmatch = require("pixelmatch");
var PNG = require("pngjs").PNG;
var sharp = require("sharp");
var temp = require("temp").track();
var U = require("glace-utils");
sharp.cache(false);
var Image = function (srcPath, srcOpts, injects) {
if (!(this instanceof Image))
return new Image(srcPath, srcOpts);
srcOpts = U.defVal(srcOpts, {});
srcOpts.scaleX = U.defVal(srcOpts.scaleX, 1);
srcOpts.scaleY = U.defVal(srcOpts.scaleY, 1);
this._srcPath = srcPath;
this._srcOpts = srcOpts;
this._pixelDenominator = 255 * Math.sqrt(3);
injects = U.defVal(injects, {});
this.__sharp = U.defVal(injects.sharp, sharp);
};
/**
* Defines whether processed image is transparent.
*
* @async
* @method
* @return {Promise<boolean>} - `true` if image is transparent, `false`
* otherwise.
*/
Image.prototype.isTransparent = function () {
return this._loadImg(this._srcPath, this._srcOpts).then(image => {
var pixels = this._getPixels(image);
var height = pixels.length;
var width = pixels[0].length;
for (var y = 0; y < height; y++) {
var row = pixels[y];
for (var x = 0; x < width; x++) {
if (row[x].A !== 0) return false;
};
};
return true;
});
};
/**
* Defines whether processed image is monochrome.
*
* @async
* @method
* @return {Promise<boolean>} - `true` if image is monochrome, `false`
* otherwise.
*/
Image.prototype.isMonochrome = function () {
return this._loadImg(this._srcPath, this._srcOpts).then(image => {
var pixels = this._getPixels(image);
var height = pixels.length;
var width = pixels[0].length;
var firstPixel = pixels[0][0];
for (var y = 0; y < height; y++) {
var row = pixels[y];
for (var x = 0; x < width; x++) {
if (row[x].A !== firstPixel.A ||
row[x].R !== firstPixel.R ||
row[x].G !== firstPixel.G ||
row[x].B !== firstPixel.B) {
return false;
};
};
};
return true;
});
};
/**
* Defines whether processed image is black.
*
* @async
* @method
* @return {Promise<boolean>} - `true` if image is black, `false` otherwise.
*/
Image.prototype.isBlack = function () {
return this._loadImg(this._srcPath, this._srcOpts).then(image => {
var pixels = this._getPixels(image);
var height = pixels.length;
var width = pixels[0].length;
for (var y = 0; y < height; y++) {
var row = pixels[y];
for (var x = 0; x < width; x++) {
if (row[x].A !== 255 ||
row[x].R !== 0 ||
row[x].G !== 0 ||
row[x].B !== 0) {
return false;
};
};
};
return true;
});
};
/**
* Defines whether processed image is white.
*
* @async
* @method
* @return {Promise<boolean>} - `true` if image is white, `false` otherwise.
*/
Image.prototype.isWhite = function () {
return this._loadImg(this._srcPath, this._srcOpts).then(image => {
var pixels = this._getPixels(image);
var height = pixels.length;
var width = pixels[0].length;
for (var y = 0; y < height; y++) {
var row = pixels[y];
for (var x = 0; x < width; x++) {
if (row[x].A !== 255 ||
row[x].R !== 255 ||
row[x].G !== 255 ||
row[x].B !== 255) {
return false;
};
};
};
return true;
});
};
/**
* Defines if processed image includes specified image.
*
* @method
* @arg {string} dstPath - path to potentially included image
* @arg {object} [dstOpts] - included image options
* @arg {number} [dstOpts.tolerance=0.05] - comparison tolerance
* @arg {?string} [dstOpts.matchedPath=null] - path to same captured place
* @arg {boolean} [dstOpts.saveMatch=false] - flag to capture images
* intersection or no
* @return {Promise<object>} - result
*/
Image.prototype.includes = function (dstPath, dstOpts) {
dstOpts = U.defVal(dstOpts, {});
dstOpts.scaleX = U.defVal(dstOpts.scaleX, 1);
dstOpts.scaleY = U.defVal(dstOpts.scaleY, 1);
this._dstPath = dstPath;
this._dstOpts = dstOpts;
this._tolerance = U.defVal(dstOpts.tolerance, .05);
this._matchedPath = U.defVal(dstOpts.matchedPath);
this._saveMatch = U.defVal(dstOpts.saveMatch, !!this._matchedPath);
if (this._saveMatch && !this._matchedPath)
this._matchedPath = temp.path({ suffix: ".png" });
var srcImage;
return this._loadImg(this._srcPath, this._srcOpts).then(image => {
srcImage = image;
return this._loadImg(this._dstPath, this._dstOpts);
}).then(dstImage => {
return this._includes(srcImage, dstImage);
}).then(result => {
if (!result.isIncluded) return { isIncluded: false };
if (!this._saveMatch) return result;
return this
._saveMatchImage(result.offsetX,
result.offsetY,
result.width,
result.height)
.then(() => {
result.matchedPath = this._matchedPath;
return result;
});
});
};
/**
* Defines if processed image equal to specified image.
*
* @method
* @arg {string} dstPath - path to potentially equal image
* @arg {object} [dstOpts] - included image options
* @arg {number} [dstOpts.tolerance=0.05] - comparison tolerance
* @arg {?string} [dstOpts.diffPath=null] - path to save captured difference.
* @arg {boolean} [dstOpts.saveDiff=false] - flag to capture images
* intersection or no
* @return {Promise<number>} Ratio of difference.
*/
Image.prototype.equalTo = function (dstPath, dstOpts) {
dstOpts = U.defVal(dstOpts, {});
dstOpts.scaleX = U.defVal(dstOpts.scaleX, 1);
dstOpts.scaleY = U.defVal(dstOpts.scaleY, 1);
var tolerance = U.defVal(dstOpts.tolerance, .05);
var diffPath = U.defVal(dstOpts.diffPath);
var saveDiff = U.defVal(dstOpts.saveDiff, !!diffPath);
if (saveDiff && !diffPath) {
diffPath = temp.path({ suffix: ".png" });
}
var srcImage;
return this._loadImg(this._srcPath, this._srcOpts).then(image => {
srcImage = image;
return this._loadImg(dstPath, dstOpts);
}).then(dstImage => {
var diffImage, diffData = null;
if (diffPath) {
diffImage = new PNG({ width: srcImage.width, height: srcImage.height });
diffData = diffImage.data;
};
var diffPixels = pixelmatch(
srcImage.data, dstImage.data, diffData,
srcImage.width, srcImage.height, { threshold: tolerance });
var diff = _.round(diffPixels / (srcImage.width * srcImage.height), 2);
if (diffImage) {
return new Promise((resolve, reject) => {
var stream = fs.createWriteStream(diffPath);
stream.on("finish", () => stream.close(() => resolve(diff)));
stream.on("error", reject);
diffImage.pack().pipe(stream);
});
} else {
return diff;
}
});
};
/**
* Loads image.
*
* @async
* @method
* @protected
* @arg {string} imgPath - path loaded image
* @arg {object} imgOpts - image options
* @return {Promise<object>} - image data
*/
Image.prototype._loadImg = function(imgPath, imgOpts) {
var width, height, hasAlpha, channels;
var img = this.__sharp(imgPath);
return img.metadata().then(metadata => {
hasAlpha = metadata.hasAlpha;
channels = metadata.channels;
width = Math.ceil(metadata.width / imgOpts.scaleX);
height = Math.ceil(metadata.height / imgOpts.scaleY);
return img.resize(width, height).raw().toBuffer();
}).then(data => {
return { width: width,
height: height,
data: data,
channels: channels,
hasAlpha: hasAlpha };
});
};
/**
* Saves matched part of image.
*
* @async
* @method
* @protected
* @arg {number} left - offset from left image border
* @arg {number} top - offset from top image border
* @arg {number} width - width of matched part
* @arg {number} height - height of matched part
* @return {Promise<void>}
*/
Image.prototype._saveMatchImage = function (left, top, width, height) {
return this.__sharp(this._srcPath)
.extract({ left: left, top: top, width: width, height: height })
.toFile(this._matchedPath);
};
/**
* Defines whether one image data includes another.
*
* @method
* @protected
* @arg {object} src - source image data
* @arg {object} dst - destination image data, which is potentially included
* @return {object} - result
*/
Image.prototype._includes = function (src, dst) {
var srcPixels = this._cropPixels(this._getPixels(src)),
dstPixels = this._cropPixels(this._getPixels(dst));
var offsetX, offsetY, diffValue;
var srcWidth = srcPixels[0].length,
srcHeight = srcPixels.length,
dstWidth = dstPixels[0].length,
dstHeight = dstPixels.length;
var result = { isIncluded: false,
diffValue: null,
offsetX: null,
offsetY: null,
width: dstWidth,
height: dstHeight };
var deltaX = srcWidth - dstWidth,
deltaY = srcHeight - dstHeight;
var dstUsedPixels = this._getUsedPixels(dstPixels);
for (offsetY = 0; offsetY <= deltaY; offsetY++) {
for (offsetX = 0; offsetX <= deltaX; offsetX++) {
diffValue = this._getDiffValue(srcPixels, dstUsedPixels,
offsetX, offsetY);
if (diffValue === null) continue;
if (result.diffValue === null || result.diffValue > diffValue) {
result.isIncluded = true;
result.diffValue = diffValue;
result.offsetX = offsetX;
result.offsetY = offsetY;
};
if (diffValue === 0 || !this._saveMatch) return result;
};
};
return result;
};
/**
* Gets used pixels.
*
* @method
* @protected
* @arg {object[][]} pixels - pixels matrix
* @return {object[]} List of used pixels.
*/
Image.prototype._getUsedPixels = function (pixels) {
var usedPixels = [],
width = pixels[0].length,
height = pixels.length,
x, y,
row;
for (y = 0; y < height; y++) {
row = pixels[y];
for (x = 0; x < width; x++) {
if (this._isPixelUsed(row[x])) {
usedPixels.push({ pixel: row[x], x: x, y: y });
};
};
};
return usedPixels;
};
/**
* Calculates difference between source pixels and destination pixels.
*
* @method
* @protected
* @arg {object[][]} srcPixels - source pixels matrix
* @arg {object[][]} dstPixels - destination pixels matrix
* @arg {number} offsetX - source left border offset
* @arg {number} offsetY - source top border offset
* @return {number} - difference value
*/
Image.prototype._getDiffValue = function (srcPixels, dstPixels,
offsetX, offsetY) {
var diffPixels = 0,
limit = this._tolerance * dstPixels.length,
dstPixel,
srcPixel,
i;
for (i = 0; i < dstPixels.length; i++) {
dstPixel = dstPixels[i];
srcPixel = srcPixels[offsetY + dstPixel.y][offsetX + dstPixel.x];
if (this._isPixelTolerant(srcPixel, dstPixel.pixel))
continue;
diffPixels++;
if (diffPixels > limit) return null;
};
return diffPixels / dstPixels.length;
};
/**
* Defines whether pixel should be used for difference calculation or no,
* according its alpha value.
*
* @method
* @protected
* @arg {object} pixel - pixel
* @return {boolean} - `true` if should be, `false` otherwise
*/
Image.prototype._isPixelUsed = function (pixel) {
return (255 - pixel.A) / 255 < this._tolerance;
};
/**
* Defines whether source pixel is tolerant to destination pixel.
*
* @method
* @protected
* @arg {object} srcPixel - source pixel
* @arg {object} dstPixel - destination pixel
* @return {boolean} - `true` if pixels are tolerant, `false` otherwise
*/
Image.prototype._isPixelTolerant = function(srcPixel, dstPixel) {
var deltaR = srcPixel.R - dstPixel.R,
deltaG = srcPixel.G - dstPixel.G,
deltaB = srcPixel.B - dstPixel.B;
return Math.sqrt(deltaR * deltaR +
deltaG * deltaG +
deltaB * deltaB) /
this._pixelDenominator < this._tolerance;
};
/**
* Retrieves pixels from image data.
*
* @method
* @protected
* @arg {object} img - image data
* @return {object[][]} - pixels matrix
*/
Image.prototype._getPixels = function (img) {
var pixels = [],
height = img.height,
width = img.width * img.channels,
x, y, offset;
for (y = 0; y < height; y++) {
offset = width * y;
pixels.push([]);
for (x = 0; x < width; x += img.channels) {
pixels[y].push({
R: img.data[offset + x],
G: img.data[offset + x + 1],
B: img.data[offset + x + 2],
A: img.hasAlpha ? img.data[offset + x + 3] : 255,
});
};
};
return pixels;
};
/**
* Crops pixels, removes empty rows and columns.
*
* @method
* @protected
* @arg {object[][]} pixels - pixels matrix
* @return {object[][]} - cropped pixels matrix
*/
Image.prototype._cropPixels = function (pixels) {
pixels = this._cropTop(pixels);
pixels.reverse();
pixels = this._cropTop(pixels);
pixels.reverse();
pixels = this._transpose(pixels);
pixels = this._cropTop(pixels);
pixels.reverse();
pixels = this._cropTop(pixels);
pixels.reverse();
return this._transpose(pixels);
};
/**
* Crops top part of pixels matrix.
*
* @method
* @protected
* @arg {object[][]} pixels - pixels matrix
* @return {object[][]} - cropped pixels matrix
*/
Image.prototype._cropTop = function (pixels) {
var croppedPixels = [],
isTransparent = true,
width = pixels[0].length,
height = pixels.length,
x, y, pixelsRow;
for (y = 0; y < height; y++) {
pixelsRow = pixels[y];
if (isTransparent) {
for (x = 0; x < width; x++) {
if (pixelsRow[x].A !== 0) {
isTransparent = false;
break;
};
};
};
if (!isTransparent)
croppedPixels.push(pixelsRow);
};
return croppedPixels;
};
/**
* Transposes pixels matrix.
*
* @method
* @arg {object[][]} pixels - pixels matrix
* @return {object[][]} transposed pixels matrix
*/
Image.prototype._transpose = function (pixels) {
var width = pixels[0].length,
height = pixels.length,
tPixels = [],
x, y;
if (width === 0 || height === 0)
return [];
for (y = 0; y < width; y++) {
tPixels[y] = [];
for (x = 0; x < height; x++) {
tPixels[y][x] = pixels[x][y];
};
};
return tPixels;
};
module.exports = Image;