Skip to content

Commit

Permalink
Merge pull request #85 from gemini-testing/HERMIONE-451.migrate_to_sharp
Browse files Browse the repository at this point in the history
feat: add different files format support
  • Loading branch information
KuznetsovRoman authored Nov 21, 2022
2 parents 04e9de0 + d32441a commit 50fd0c7
Show file tree
Hide file tree
Showing 24 changed files with 1,058 additions and 285 deletions.
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@

[![Build Status](https://travis-ci.org/gemini-testing/looks-same.svg?branch=master)](https://travis-ci.org/gemini-testing/looks-same)

Node.js library for comparing PNG-images, taking into account human color
Node.js library for comparing images, taking into account human color
perception. It is created specially for the needs of visual regression testing
for [`hermione`](http://github.com/gemini-testing/hermione) utility, but can be used
for other purposes.

## Supported image formats

JPEG, PNG, WebP, GIF, AVIF, TIFF and SVG images are supported.

*Note: If you want to compare jpeg files, you may encounter random differences due to the jpeg structure if they are not lossless jpeg files.*

## Comparing images

```javascript
Expand All @@ -16,7 +22,7 @@ const looksSame = require('looks-same');
const {equal} = await looksSame('image1.png', 'image2.png');
```

Parameters can be paths to files or buffer with compressed `png` image.
Parameters can be paths to files or buffer with compressed image.

By default, it will detect only noticeable differences. If you wish to detect any difference,
use `strict` options:
Expand Down Expand Up @@ -109,12 +115,15 @@ await looksSame.createDiff({
## Building diff image as a Buffer

If you don't want the diff image to be written on disk, then simply **don't**
pass any `diff: path` to the `createDiff` method. The callback will then
receive a `Buffer` containing the diff as the 2nd argument.
pass any `diff: path` to the `createDiff` method. The method will then
resolve a `Buffer` containing the diff. You can also specify buffer format
with `extension` key. Default extension is `png`. List of supported formats:
*`heic`, `heif`, `avif`, `jpeg`, `jpg`, `png`, `raw`, `tiff`, `tif`, `webp`, `gif`, `jp2`, `jpx`, `j2k`, `j2c`*

```javascript
const buffer = await looksSame.createDiff({
// exactly same options as above, but without diff
// exactly same options as above, but with optional extension and without diff
extension: 'png'
});
```

Expand Down
55 changes: 32 additions & 23 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
const _ = require('lodash');
const parseColor = require('parse-color');
const colorDiff = require('color-diff');
const png = require('./lib/png-image');
const img = require('./lib/image');
const areColorsSame = require('./lib/same-colors');
const AntialiasingComparator = require('./lib/antialiasing-comparator');
const IgnoreCaretComparator = require('./lib/ignore-caret-comparator');
const DiffArea = require('./lib/diff-area');
const utils = require('./lib/utils');
const {JND} = require('./lib/constants');

const makeAntialiasingComparator = (comparator, png1, png2, opts) => {
const antialiasingComparator = new AntialiasingComparator(comparator, png1, png2, opts);
const makeAntialiasingComparator = (comparator, img1, img2, opts) => {
const antialiasingComparator = new AntialiasingComparator(comparator, img1, img2, opts);
return (data) => antialiasingComparator.compare(data);
};

Expand All @@ -34,11 +34,11 @@ function makeCIEDE2000Comparator(tolerance) {
};
}

const createComparator = (png1, png2, opts) => {
const createComparator = (img1, img2, opts) => {
let comparator = opts.strict ? areColorsSame : makeCIEDE2000Comparator(opts.tolerance);

if (opts.ignoreAntialiasing) {
comparator = makeAntialiasingComparator(comparator, png1, png2, opts);
comparator = makeAntialiasingComparator(comparator, img1, img2, opts);
}

if (opts.ignoreCaret) {
Expand Down Expand Up @@ -70,31 +70,39 @@ const iterateRect = async (width, height, callback) => {
});
};

const buildDiffImage = async (png1, png2, options) => {
const width = Math.max(png1.width, png2.width);
const height = Math.max(png1.height, png2.height);
const minWidth = Math.min(png1.width, png2.width);
const minHeight = Math.min(png1.height, png2.height);
const buildDiffImage = async (img1, img2, options) => {
const width = Math.max(img1.width, img2.width);
const height = Math.max(img1.height, img2.height);
const minWidth = Math.min(img1.width, img2.width);
const minHeight = Math.min(img1.height, img2.height);

const highlightColor = options.highlightColor;
const result = png.empty(width, height);
const resultBuffer = Buffer.alloc(width * height * 3);

const setPixel = (buf, x, y, {R, G, B}) => {
const pixelInd = (y * width + x) * 3;
buf[pixelInd] = R;
buf[pixelInd + 1] = G;
buf[pixelInd + 2] = B;
};

await iterateRect(width, height, (x, y) => {
if (x >= minWidth || y >= minHeight) {
result.setPixel(x, y, highlightColor);
setPixel(resultBuffer, x, y, highlightColor);
return;
}

const color1 = png1.getPixel(x, y);
const color2 = png2.getPixel(x, y);
const color1 = img1.getPixel(x, y);
const color2 = img2.getPixel(x, y);

if (!options.comparator({color1, color2, png1, png2, x, y, width, height})) {
result.setPixel(x, y, highlightColor);
if (!options.comparator({color1, color2, img1, img2, x, y, width, height})) {
setPixel(resultBuffer, x, y, highlightColor);
} else {
result.setPixel(x, y, color1);
setPixel(resultBuffer, x, y, color1);
}
});

return result;
return img.fromBuffer(resultBuffer, {raw: {width, height, channels: 3}});
};

const parseColorString = (str) => {
Expand Down Expand Up @@ -163,16 +171,16 @@ module.exports = exports = async function looksSame(image1, image2, opts = {}) {
return {equal: false, metaInfo, diffBounds, diffClusters: [diffBounds]};
}

const {first: png1, second: png2} = await utils.readPair(
const {first: img1, second: img2} = await utils.readPair(
{...image1, source: first.buffer},
{...image2, source: second.buffer},
utils.readPngCb
utils.readImgCb
);

const comparator = createComparator(png1, png2, opts);
const comparator = createComparator(img1, img2, opts);
const {stopOnFirstFail, shouldCluster, clustersSize} = opts;

const {diffArea, diffClusters} = await utils.getDiffPixelsCoords(png1, png2, comparator, {stopOnFirstFail, shouldCluster, clustersSize});
const {diffArea, diffClusters} = await utils.getDiffPixelsCoords(img1, img2, comparator, {stopOnFirstFail, shouldCluster, clustersSize});
const diffBounds = diffArea.area;
const equal = diffArea.isEmpty();

Expand Down Expand Up @@ -202,6 +210,7 @@ exports.getDiffArea = async function(image1, image2, opts = {}) {

exports.createDiff = async function saveDiff(opts) {
opts = prepareOpts(opts);
opts.extension = opts.extension || 'png';

const [image1, image2] = utils.formatImages(opts.reference, opts.current);
const {first, second} = await utils.readPair(image1, image2);
Expand All @@ -211,7 +220,7 @@ exports.createDiff = async function saveDiff(opts) {
});

return opts.diff === undefined
? diffImage.createBuffer()
? diffImage.createBuffer(opts.extension)
: diffImage.save(opts.diff);
};

Expand Down
6 changes: 3 additions & 3 deletions lib/antialiasing-comparator.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
const DEFAULT_BRIGHTNESS_TOLERANCE = 0;

module.exports = class AntialiasingComparator {
constructor(baseComparator, png1, png2, {antialiasingTolerance = 0}) {
constructor(baseComparator, img1, img2, {antialiasingTolerance = 0}) {
this._baseComparator = baseComparator;
this._img1 = png1;
this._img2 = png2;
this._img1 = img1;
this._img2 = img2;
this._brightnessTolerance = antialiasingTolerance; // used only when comparing the darkest and the brightest pixels
}

Expand Down
2 changes: 1 addition & 1 deletion lib/ignore-caret-comparator/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ module.exports = class IgnoreCaretComparator {
}

_checkIsCaret(data) {
return this._state.validate(_.pick(data, ['x', 'y']), _.pick(data, ['png1', 'png2']));
return this._state.validate(_.pick(data, ['x', 'y']), _.pick(data, ['img1', 'img2']));
}

switchState(stateName) {
Expand Down
6 changes: 3 additions & 3 deletions lib/ignore-caret-comparator/states/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ module.exports = class InitState extends State {
}

_isPointOutsideImages(point, imgs) {
return _.some(imgs, (png) => point.x >= png.width || point.y >= png.height);
return _.some(imgs, (img) => point.x >= img.width || point.y >= img.height);
}

_areColorsSame(point, imgs) {
const color1 = imgs.png1.getPixel(point.x, point.y);
const color2 = imgs.png2.getPixel(point.x, point.y);
const color1 = imgs.img1.getPixel(point.x, point.y);
const color2 = imgs.img2.getPixel(point.x, point.y);

return areColorsSame({color1, color2});
}
Expand Down
2 changes: 1 addition & 1 deletion lib/png-base.js → lib/image-base.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict';

module.exports = class PNGBase {
module.exports = class ImageBase {
static create(...args) {
return new this(...args);
}
Expand Down
14 changes: 4 additions & 10 deletions lib/png-image/bounded-png.js → lib/image/bounded-image.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
'use strict';

const PNGImage = require('./png');
const Image = require('./image');

module.exports = class BoundedPNGImage extends PNGImage {
constructor(png, boundingBox) {
super(png);
module.exports = class BoundedImage extends Image {
constructor(img, boundingBox) {
super(img);

this._boundingBox = boundingBox;
}
Expand All @@ -15,12 +15,6 @@ module.exports = class BoundedPNGImage extends PNGImage {
return super.getPixel(actX, actY);
}

setPixel(x, y, color) {
const {x: actX, y: actY} = this.getActualCoord(x, y);

super.setPixel(actX, actY, color);
}

getActualCoord(x, y) {
return {x: x + this._boundingBox.left, y: y + this._boundingBox.top};
}
Expand Down
41 changes: 41 additions & 0 deletions lib/image/image.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use strict';

const ImageBase = require('../image-base');

module.exports = class Image extends ImageBase {
constructor(img) {
super();

this._img = img;
}

async init() {
const {data, info} = await this._img.raw().toBuffer({resolveWithObject: true});

this._buffer = data;
this._width = info.width;
this._height = info.height;
this._channels = info.channels;
}

getPixel(x, y) {
const idx = this._getIdx(x, y);
return {
R: this._buffer[idx],
G: this._buffer[idx + 1],
B: this._buffer[idx + 2]
};
}

_getIdx(x, y) {
return (this._width * y + x) * this._channels;
}

async save(path) {
return this._img.toFile(path);
}

async createBuffer(extension) {
return this._img.toFormat(extension).toBuffer();
}
};
27 changes: 27 additions & 0 deletions lib/image/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use strict';

const fs = require('fs-extra');
const NestedError = require('nested-error-stacks');
const sharp = require('sharp');
const OriginalIMG = require('./original-image');
const BoundedIMG = require('./bounded-image');

const createimage = async (img, {boundingBox} = {}) => {
return boundingBox
? BoundedIMG.create(img, boundingBox)
: OriginalIMG.create(img);
};

exports.fromBuffer = async (buffer, opts) => {
const img = sharp(buffer, opts);
return createimage(img, opts);
};

exports.fromFile = async (filePath, opts = {}) => {
try {
const buffer = await fs.readFile(filePath);
return exports.fromBuffer(buffer, opts);
} catch (err) {
throw new NestedError(`Can't load img file ${filePath}`, err);
}
};
17 changes: 17 additions & 0 deletions lib/image/original-image.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use strict';

const Image = require('./image');

module.exports = class OriginalImage extends Image {
getActualCoord(x, y) {
return {x, y};
}

get width() {
return this._width;
}

get height() {
return this._height;
}
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use strict';

const PNGBuffer = require('./buffer');
const IMGBuffer = require('./buffer');

module.exports = class BoundedPNGBuffer extends PNGBuffer {
module.exports = class BoundedIMGBuffer extends IMGBuffer {
constructor(buffer, boundingBox) {
super(buffer);

Expand Down
4 changes: 2 additions & 2 deletions lib/png-buffer/buffer.js → lib/img-buffer/buffer.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use strict';

const PNGBase = require('../png-base');
const ImageBase = require('../image-base');

module.exports = class PNGBuffer extends PNGBase {
module.exports = class IMGBuffer extends ImageBase {
constructor(buffer) {
super();

Expand Down
2 changes: 1 addition & 1 deletion lib/png-buffer/index.js → lib/img-buffer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ exports.fromFile = async (filePath, opts = {}) => {
const buffer = await fs.readFile(filePath);
return exports.create(buffer, opts);
} catch (err) {
throw new NestedError(`Can't load png file ${filePath}`, err);
throw new NestedError(`Can't load img file ${filePath}`, err);
}
};
20 changes: 20 additions & 0 deletions lib/img-buffer/original-buffer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use strict';

const IMGBuffer = require('./buffer');

const IMG_WIDTH_OFFSET = 16;
const IMG_HEIGHT_OFFSET = 20;

module.exports = class OriginalIMGBuffer extends IMGBuffer {
getActualCoord(x, y) {
return {x, y};
}

get width() {
return this._buffer.readUInt32BE(IMG_WIDTH_OFFSET);
}

get height() {
return this._buffer.readUInt32BE(IMG_HEIGHT_OFFSET);
}
};
Loading

0 comments on commit 50fd0c7

Please sign in to comment.