├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── AUTHORS ├── CHANGELOG.md ├── LICENSE ├── README.md ├── benchmark ├── README.md ├── constants.js ├── fixtures │ ├── actual.png │ └── reference.png ├── image-generator │ ├── constants.js │ ├── contrast-to-reference-generator.js │ ├── fixed-visible-diff-amount-generator.js │ ├── hist-cie-diff-colors-generator.js │ ├── image-generator.js │ ├── index.js │ ├── reference-generator.js │ ├── utils.js │ ├── web-average-failed-generator.js │ └── web-average-success-generator.js ├── index.js ├── package-lock.json ├── package.json ├── progress-bar.js ├── results.md ├── tasks │ ├── blink-diff.js │ ├── hooks │ │ ├── cache.js │ │ └── index.js │ ├── looks-same.js │ ├── pixelmatch.js │ └── resemble.js └── utils.js ├── index.d.ts ├── index.js ├── lib ├── antialiasing-comparator.js ├── constants.js ├── diff-area.js ├── diff-clusters │ ├── clusters-joiner.js │ └── index.js ├── ignore-caret-comparator │ ├── index.js │ └── states │ │ ├── caret-detected.js │ │ ├── init.js │ │ └── state.js ├── image-base.js ├── image │ ├── bounded-image.js │ ├── image.js │ ├── index.js │ └── original-image.js ├── img-buffer │ ├── bounded-buffer.js │ ├── buffer.js │ ├── index.js │ └── original-buffer.js ├── same-colors.js ├── utils.js └── validators.js ├── package-lock.json ├── package.json └── test ├── .eslintrc.js ├── assert-ext.js ├── data ├── diffs │ ├── small-green.png │ ├── small-magenta.png │ ├── strict.png │ ├── taller-magenta.png │ └── wider-magenta.png └── src │ ├── 1px-diff.png │ ├── antialiasing-actual.png │ ├── antialiasing-ref.png │ ├── antialiasing-tolerance-actual-1.png │ ├── antialiasing-tolerance-actual-2.png │ ├── antialiasing-tolerance-ref-1.png │ ├── antialiasing-tolerance-ref-2.png │ ├── blue.png │ ├── bounding-box-diff-1.png │ ├── bounding-box-diff-2.png │ ├── bounding-box-ref-1.png │ ├── bounding-box-ref-2.png │ ├── broken-caret.png │ ├── caret+antialiasing.png │ ├── caret+text.png │ ├── caret.png │ ├── different-unnoticable.png │ ├── different.png │ ├── green.png │ ├── large-different.png │ ├── large-ref.png │ ├── no-caret+antialiasing.png │ ├── no-caret+text.png │ ├── no-caret.png │ ├── not-only-caret.png │ ├── red.png │ ├── ref.png │ ├── same.png │ ├── tall-different.png │ ├── tall.png │ ├── two-caret.png │ ├── wide-different.png │ └── wide.png ├── diff-area.js ├── diff-clusters ├── clusters-joiner.js └── index.js ├── ignore-caret-comparator.js ├── mocha.opts ├── png └── index.js ├── setup.js ├── test.js ├── utils.js └── validators.js /.eslintignore: -------------------------------------------------------------------------------- 1 | **/.* 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'gemini-testing', 3 | root: true 4 | }; 5 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x, 20.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm ci 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_store 2 | .DS_Store 3 | build 4 | 5 | .idea 6 | .vscode 7 | *.swo 8 | *.swp 9 | *.iml 10 | .fuse* 11 | .project 12 | node_modules 13 | artifacts 14 | npm-debug.log 15 | .editorconfig 16 | coverage/ 17 | .nyc_output/ 18 | .eslintcache 19 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/.* 2 | test/ 3 | coverage/ 4 | benchmark 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | The following authors have created the source code of "LooksSame" 2 | published and distributed by YANDEX LLC as the owner: 3 | 4 | Sergey Tatarintsev 5 | Andrey Kuznecov 6 | Alexandr Tikvach 7 | Ilia Isupov 8 | 9 | Contributers: 10 | 11 | Alex Baumgertner 12 | Linus Unnebäck 13 | Florentin Simion 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [9.0.1](https://github.com/gemini-testing/looks-same/compare/v9.0.0...v9.0.1) (2024-08-09) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * remove async from index.d.ts ([59a6a1c](https://github.com/gemini-testing/looks-same/commit/59a6a1cd26455dd133d5da7aca1d569694e9ce17)) 11 | 12 | ## [9.0.0](https://github.com/gemini-testing/looks-same/compare/v8.2.4...v9.0.0) (2023-10-31) 13 | 14 | 15 | ### ⚠ BREAKING CHANGES 16 | 17 | * node versions less than 18.0.0 are no longer supported 18 | 19 | ### Features 20 | 21 | * drop node versions less than 18 ([1f0097c](https://github.com/gemini-testing/looks-same/commit/1f0097ca7e29e11ba19b82a821c726171505b446)) 22 | 23 | 24 | ### Bug Fixes 25 | 26 | * run linters ([9d839dd](https://github.com/gemini-testing/looks-same/commit/9d839dd016ac221e03264b2540150ab317c6c290)) 27 | * update sharp to fix CVE-2023-4863 ([9b3a0a1](https://github.com/gemini-testing/looks-same/commit/9b3a0a109f3676a0ea00ba1a0b16e1756a5e6bd1)) 28 | 29 | ### [8.2.4](https://github.com/gemini-testing/looks-same/compare/v8.2.3...v8.2.4) (2023-10-11) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * return diffArea in diffClusters if shouldCluster is false ([0c92966](https://github.com/gemini-testing/looks-same/commit/0c929665092fdc79e5db6bb56d7982eeb01ec4e6)) 35 | 36 | ### [8.2.3](https://github.com/gemini-testing/looks-same/compare/v8.2.2...v8.2.3) (2023-09-27) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * createDiffImage off by one ([cd9744b](https://github.com/gemini-testing/looks-same/commit/cd9744b576e56b957f33c3ff2ec3c41a76457a62)) 42 | 43 | ### [8.2.2](https://github.com/gemini-testing/looks-same/compare/v8.2.1...v8.2.2) (2023-09-25) 44 | 45 | ### [8.2.1](https://github.com/gemini-testing/looks-same/compare/v8.2.0...v8.2.1) (2023-08-02) 46 | 47 | ## [8.2.0](https://github.com/gemini-testing/looks-same/compare/v8.1.0...v8.2.0) (2023-08-02) 48 | 49 | 50 | ### Features 51 | 52 | * calc equality and build diff simultaneously ([abbe9ed](https://github.com/gemini-testing/looks-same/commit/abbe9ed29f18d656317097053e74afe11bacda44)) 53 | 54 | ## [8.1.0](https://github.com/gemini-testing/looks-same/compare/v8.0.0...v8.1.0) (2022-11-21) 55 | 56 | 57 | ### Features 58 | 59 | * add different files format support ([d32441a](https://github.com/gemini-testing/looks-same/commit/d32441a31cfa1d7f5ab0cf21663c01e8bad4e87f)) 60 | 61 | ## [8.0.1](https://github.com/gemini-testing/looks-same/compare/v8.0.0...v8.0.1) (2022-09-22) 62 | 63 | 64 | ### Typings 65 | 66 | * Update typings to async/await interface 67 | * Export interfaces 68 | 69 | ## [8.0.0](https://github.com/gemini-testing/looks-same/compare/v7.3.0...v8.0.0) (2022-09-19) 70 | 71 | 72 | ### ⚠ BREAKING CHANGES 73 | 74 | * drop support of node versions less than 12 75 | * dropped old node-style callback interface support 76 | 77 | ### Features 78 | 79 | * async-await interface ([b33a6f9](https://github.com/gemini-testing/looks-same/commit/b33a6f925701a3ed6cfe9479cf3d8ad290320be5)) 80 | * increase supported node version up to 12 ([ae1e4b2](https://github.com/gemini-testing/looks-same/commit/ae1e4b265ee3f7af25e526256fdd4970b568ff7a)) 81 | 82 | ## [7.3.0](https://github.com/gemini-testing/looks-same/compare/v7.2.4...v7.3.0) (2021-02-03) 83 | 84 | 85 | ### Features 86 | 87 | * add ability to compare screens by buffers ([#75](https://github.com/gemini-testing/looks-same/issues/75)) ([039ab0e](https://github.com/gemini-testing/looks-same/commit/039ab0e5ac2b591a46565677a562d3b6898ba4c5)) 88 | 89 | ### [7.2.4](https://github.com/gemini-testing/looks-same/compare/v7.2.3...v7.2.4) (2020-11-13) 90 | 91 | ### [7.2.3](https://github.com/gemini-testing/looks-same/compare/v7.2.2...v7.2.3) (2020-05-08) 92 | 93 | 94 | ### Bug Fixes 95 | 96 | * **prepareOpts:** TypeError when opts is undefined ([#66](https://github.com/gemini-testing/looks-same/issues/66)) ([c6ea6c2](https://github.com/gemini-testing/looks-same/commit/c6ea6c2de99a82e1cf798264e87c3d057f1ae32f)) 97 | 98 | ### [7.2.2](https://github.com/gemini-testing/looks-same/compare/v7.2.1...v7.2.2) (2019-10-28) 99 | 100 | 101 | ### Bug Fixes 102 | 103 | * unknown file path in parse png error ([15898d4](https://github.com/gemini-testing/looks-same/commit/15898d4832d7f7ddbf50eab1704ec9bcd093c394)) 104 | 105 | ## 4.1.0 - 2018-12-05 106 | 107 | * add ability to ignore antialiasing and caret in "createDiff" method 108 | * add typescript types 109 | 110 | ## 4.0.0 - 2018-09-11 111 | 112 | * Update nodejs to 6 version 113 | * Add ability to make ignore antialiasing less strict 114 | 115 | ## 3.3.0 - 2017-12-26 116 | 117 | * Add `getDiffArea` method 118 | 119 | ## 3.2.0 - 2017-01-18 120 | 121 | * Add ability to ignore caret when it is crossing with text 122 | 123 | ## 3.1.0 - 2016-11-11 124 | 125 | * Add `ignoreAntialiasing` option to ignore diffs with anti-aliased pixels. Enabled by default. 126 | 127 | ## 3.0.0 - 2016-07-13 128 | 129 | * Remove support for 0.10 and 0.12 NodeJS versions. 130 | * Fix ignore caret on devices with `pixelRatio` > 1. 131 | * Fix bug with the missed 1px diff between images when `ignoreCaret` option is enabled. 132 | 133 | ## 2.2.2 - 2015-12-19 134 | 135 | * Use `pngjs2` instead of `lodepng` (@SevInf). 136 | 137 | ## 2.2.1 - 2015-09-11 138 | 139 | * Use `lodepng` for png encoding/decoding (@LinusU). 140 | 141 | ## 2.2.0 - 2015-09-11 142 | 143 | * Expose color comparsion function `looksSame.colors` (@SevInf). 144 | 145 | ## 2.1.0 - 2015-08-07 146 | 147 | * Allow to receive diff image as a Buffer (@flore77). 148 | 149 | ## 2.0.0 - 2015-07-14 150 | 151 | * Fix critical bug in color comparison algorithm. 152 | Published as 2.0.0 because the result of the comparison 153 | will change for many images and affect the dependencies. 154 | 155 | ## 1.1.1 - 2015-02-12 156 | 157 | * Setting both `tolerance` and `strict` fails 158 | only if `strict` is set to `true`. 159 | 160 | ## 1.1.0 - 2015-02-11 161 | 162 | * Ability to configure tolerance. 163 | 164 | ## 1.0.1 - 2014-10-01 165 | 166 | * Correctly read RGB values from image. 167 | 168 | ## 1.0.0 - 2014-09-29 169 | 170 | * Initial release 171 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 YANDEX LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LooksSame 2 | 3 | [![Build Status](https://travis-ci.org/gemini-testing/looks-same.svg?branch=master)](https://travis-ci.org/gemini-testing/looks-same) 4 | 5 | Node.js library for comparing images, taking into account human color 6 | perception. It is created specially for the needs of visual regression testing 7 | for [`testplane`](http://github.com/gemini-testing/testplane) utility, but can be used 8 | for other purposes. 9 | 10 | ## Benchmark 11 | 12 | Benchmark is presented in the corresponding directory. 13 | 14 | - [Benchmark description](./benchmark/README.md) 15 | - [Benchmark results](./benchmark/results.md) 16 | 17 | ## Supported image formats 18 | 19 | JPEG, PNG, WebP, GIF, AVIF, TIFF and SVG images are supported. 20 | 21 | *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.* 22 | 23 | ## Comparing images 24 | 25 | ```javascript 26 | const looksSame = require('looks-same'); 27 | 28 | // equal will be true, if images looks the same 29 | const {equal} = await looksSame('image1.png', 'image2.png'); 30 | ``` 31 | 32 | Parameters can be paths to files or buffer with compressed image. 33 | 34 | By default, it will detect only noticeable differences. If you wish to detect any difference, 35 | use `strict` options: 36 | 37 | ```javascript 38 | const {equal} = await looksSame('image1.png', 'image2.png', {strict: true}); 39 | ``` 40 | 41 | You can also adjust the [ΔE](http://en.wikipedia.org/wiki/Color_difference) value that will be treated as error 42 | in non-strict mode: 43 | 44 | ```javascript 45 | const {equal} = await looksSame('image1.png', 'image2.png', {tolerance: 5}); 46 | ``` 47 | 48 | Default `tolerance` in non-strict mode is 2.3 which is enough for the most cases. 49 | Setting `tolerance` to 0 will produce the same result as `strict: true`, but strict mode 50 | is faster. 51 | Attempt to set `tolerance` in strict mode will produce an error. 52 | 53 | Some devices can have different proportion between physical and logical screen resolutions also 54 | known as `pixel ratio`. Default value for this proportion is 1. 55 | This param also affects the comparison result, so it can be set manually with `pixelRatio` option. 56 | 57 | ```javascript 58 | const {equal} = await looksSame('image1.png', 'image2.png', {pixelRatio: 2}); 59 | ``` 60 | 61 | ### Comparing images with ignoring caret 62 | 63 | Text caret in text input elements it is a pain for visual regression tasks, because it is always blinks. These diffs will be ignored by default. You can use `ignoreCaret` option with `false` value to disable ignoring such diffs. In that way text caret will be marked as diffs. 64 | 65 | ```javascript 66 | const {equal} = await looksSame('image1.png', 'image2.png', {ignoreCaret: true}); 67 | ``` 68 | 69 | Both `strict` and `ignoreCaret` can be set independently of one another. 70 | 71 | ### Comparing images with ignoring antialiasing 72 | 73 | Some images has difference while comparing because of antialiasing. These diffs will be ignored by default. You can use `ignoreAntialiasing` option with `false` value to disable ignoring such diffs. In that way antialiased pixels will be marked as diffs. Read more about [anti-aliasing algorithm](http://www.eejournal.ktu.lt/index.php/elt/article/view/10058/5000). 74 | 75 | ```javascript 76 | const {equal} = await looksSame('image1.png', 'image2.png', {ignoreAntialiasing: true}); 77 | ``` 78 | 79 | Sometimes the antialiasing algorithm can work incorrectly due to some features of the browser rendering engine. Use the option `antialiasingTolerance` to make the algorithm less strict. With this option you can specify the minimum difference in brightness (zero by default) between the darkest/lightest pixel (which is adjacent to the antialiasing pixel) and theirs adjacent pixels. 80 | 81 | We recommend that you don't increase this value above 10. If you need to increase more than 10 then this is definitely not antialiasing. 82 | 83 | Example: 84 | ```javascript 85 | const {equal} = await looksSame('image1.png', 'image2.png', {ignoreAntialiasing: true, antialiasingTolerance: 3}); 86 | ``` 87 | 88 | ### Getting diff bounds 89 | Looksame returns information about diff bounds. It returns only first pixel if you passed `stopOnFirstFail` option with `true` value. The whole diff area would be returned if `stopOnFirstFail` option is not passed or it's passed with `false` value. 90 | 91 | ### Getting diff clusters 92 | Looksame returns diff bounds divided into clusters if option `shouldCluster` passed with `true` value. Moreover you can pass clusters size using `clustersSize` option. 93 | 94 | ```javascript 95 | // { 96 | // equal: false, 97 | // diffBounds: {left: 10, top: 10, right: 20, bottom: 20} 98 | // diffClusters: [ 99 | // {left: 10, top: 10, right: 14, bottom: 14}, 100 | // {left: 16, top: 16, right: 20, bottom: 20} 101 | // ] 102 | // } 103 | const {equal, diffBounds, diffClusters} = await looksSame('image1.png', 'image2.png', {shouldCluster: true, clustersSize: 10}); 104 | ``` 105 | 106 | ## Building diff image 107 | 108 | ```javascript 109 | await looksSame.createDiff({ 110 | reference: '/path/to/reference/image.png', 111 | current: '/path/to/current/image.png', 112 | diff: '/path/to/save/diff/to.png', 113 | highlightColor: '#ff00ff', // color to highlight the differences 114 | strict: false, // strict comparsion 115 | tolerance: 2.5, 116 | antialiasingTolerance: 0, 117 | ignoreAntialiasing: true, // ignore antialising by default 118 | ignoreCaret: true // ignore caret by default 119 | }); 120 | ``` 121 | 122 | ## Building diff image as a Buffer 123 | 124 | If you don't want the diff image to be written on disk, then simply **don't** 125 | pass any `diff: path` to the `createDiff` method. The method will then 126 | resolve a `Buffer` containing the diff. You can also specify buffer format 127 | with `extension` key. Default extension is `png`. List of supported formats: 128 | *`heic`, `heif`, `avif`, `jpeg`, `jpg`, `png`, `raw`, `tiff`, `tif`, `webp`, `gif`, `jp2`, `jpx`, `j2k`, `j2c`* 129 | 130 | ```javascript 131 | const buffer = await looksSame.createDiff({ 132 | // exactly same options as above, but with optional extension and without diff 133 | extension: 'png' 134 | }); 135 | ``` 136 | 137 | ## Comparing images and creating diff image simultaneously 138 | 139 | If you need both co compare images and create diff image, you can pass option `createDiffImage: true`, 140 | it would work faster than two separate function calls: 141 | 142 | ```javascript 143 | const { 144 | equal, 145 | diffImage, 146 | differentPixels, 147 | totalPixels, 148 | diffBounds, 149 | diffClusters 150 | } = await looksSame('image1.png', 'image2.png', {createDiffImage: true}); 151 | 152 | if (!equal) { 153 | await diffImage.save('diffImage.png'); 154 | } 155 | ``` 156 | 157 | ## Comparing colors 158 | 159 | If you just need to compare two colors you can use `colors` function: 160 | 161 | ```javascript 162 | const equal = looksSame.colors( 163 | {R: 255, G: 0, B: 0}, 164 | {R: 254, G: 1, B: 1}, 165 | {tolerance: 2.5} 166 | ); 167 | ``` 168 | -------------------------------------------------------------------------------- /benchmark/README.md: -------------------------------------------------------------------------------- 1 | # looks-same Benchmark 2 | 3 | This benchmark evaluates the performance of four npm packages for image comparison: [`looks-same`](https://github.com/gemini-testing/looks-same), [`pixelmatch`](https://github.com/mapbox/pixelmatch), [`resemblejs`](https://github.com/rsmbl/Resemble.js), and [`blink-diff`](https://github.com/yahoo/blink-diff). 4 | It focuses on execution speed across diverse test cases, including real-world web comparisons and synthetic examples. 5 | 6 | --- 7 | 8 | ## Test Cases 9 | 10 | ### Web-Based Averages 11 | 12 | - **Web Avg Diff (672x623)**: 13 | Aggregates **failed web page comparisons** from our production projects using [Testplane](https://testplane.io/) for e2e testing. 14 | Represents 103,142 real-world UI mismatches (e.g., layout shifts, rendering errors). 15 | - **Web Avg Success (656x547)**: 16 | Derived from 6,282,752 successful comparisons in production. 17 | Tests speed for "no difference" scenarios common in regression testing workflows. 18 | 19 | ### Synthetic Comparisons 20 | 21 | - **Equal Images (1000x1000)**: Baseline test with identical images. 22 | - **1% Visible Diff (1000x1000)**: 23 | Human-noticeable differences (ΔE ≥ 2.3 in [CIEDE2000](https://en.wikipedia.org/wiki/Color_difference#CIEDE2000)). 24 | - **10% Visible Diff (1000x1000)**: 25 | Larger-scale variations with the same perceptual thresholds. 26 | - **Full Max Diff (1000x1000)**: 27 | All pixels altered (maximum difference) to stress-test worst-case performance. 28 | 29 | ### Realistic Example 30 | 31 | - **Demonstrative Example (896×784)**: 32 | Real-world UI comparison with an 8% visible difference: a button intentionally recolored to a human-noticeable shade. 33 | Provides visual intuition for typical failure scenarios. 34 |
35 | Uses the following images (from ./fixtures) 36 | 37 | 38 | 39 | 40 | 41 | 42 |
Image 1Image 2
43 |
44 | 45 | 46 | --- 47 | 48 | ## Results 49 | 50 | See [**results.md**](./results.md) for execution times across all test cases. 51 | 52 | ## Running the benchmark 53 | 54 | 1. Clone the repository: 55 | ```bash 56 | git clone git@github.com:gemini-testing/looks-same.git 57 | ``` 58 | 2. Navigate to "benchmark" directory: 59 | ```bash 60 | cd looks-same/benchmark 61 | ``` 62 | 3. Install dependencies: 63 | ```bash 64 | npm ci 65 | ``` 66 | 4. Run the benchmark: 67 | ```bash 68 | npm start 69 | ``` 70 | -------------------------------------------------------------------------------- /benchmark/constants.js: -------------------------------------------------------------------------------- 1 | exports.PACKAGES = { 2 | LOOKS_SAME: 'looks-same', 3 | PIXELMATCH: 'pixelmatch', 4 | RESEMBLE_JS: 'resemblejs', 5 | BLINK_DIFF: 'blink-diff' 6 | }; 7 | 8 | exports.CASES = { 9 | WEB_AVG_DIFF: 'web avg diff (672x623)', 10 | WEB_AVG_SUCCESS: 'web avg success (656x547)', 11 | EQUAL_IMAGES: 'equal images (1000x1000)', 12 | ONE_PERCENT_VISIBLE_DIFF: '1% visible diff (1000x1000)', 13 | TEN_PERCENTS_VISIBLE_DIFF: '10% visible diff (1000x1000)', 14 | FULL_MAX_DIFF: 'full max diff (1000x1000)', 15 | DEMONSTRATIVE_EXAMPLE: 'demonstrative example visible 8% diff (896×784)' 16 | }; 17 | -------------------------------------------------------------------------------- /benchmark/fixtures/actual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/benchmark/fixtures/actual.png -------------------------------------------------------------------------------- /benchmark/fixtures/reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/benchmark/fixtures/reference.png -------------------------------------------------------------------------------- /benchmark/image-generator/constants.js: -------------------------------------------------------------------------------- 1 | exports.CHANNELS_COUNT = 3; 2 | exports.JND = 2.3; // CIEDE2000 just noticeable difference 3 | 4 | exports.REFERENCE_COLOR = [100, -38, -38]; // CIE-L*ab 5 | exports.CONSTRAST_REFERENCE_COLOR = [0, 128, 128]; // CIE-L*ab 6 | 7 | exports.AVERAGE_FAILED_HIST_BUCKETS = [ 8 | // in ["a", "b", "c"] tuples, "c" part of the pixels have ciede2000 diff befween "a" (inc.) and "b" (exc.) 9 | [0, 5e-324, 0.9163583710631669], 10 | [5e-324, 0.2, 0.0006867712475951489], 11 | [0.2, 0.5, 0.0021610446606488607], 12 | [0.5, 1, 0.005987159508767972], 13 | [1, 1.6, 0.011063733707154652], 14 | [1.6, 1.73, 0.0015331092837601974], 15 | [1.73, 1.8765, 0.0006625396213279764], 16 | [1.8765, 2, 0.0018387428611124819], 17 | [2, 2.3, 0.0013118358358049037], 18 | [2.3, 2.4, 0.00022329552469749336], 19 | [2.4, 2.5, 0.00036489135695552325], 20 | [2.5, 2.7, 0.008824595373243259], 21 | [2.7, 4, 0.003404259594068377], 22 | [4, 6, 0.0042124825492642895], 23 | [6, 8, 0.0030169690455660605], 24 | [8, 10, 0.0015209692289468576], 25 | [10, 12, 0.001571573898168484], 26 | [12, 14.26, 0.0011032068046126686], 27 | [14.26, 15.5, 0.000604997805480533], 28 | [15.5, 16.7, 0.0006418838778879667], 29 | [16.7, 25, 0.0032761913664942494], 30 | [25, 30, 0.004046992111565813], 31 | [30, 40, 0.008427973959907584], 32 | [40, 50, 0.004921414959461223], 33 | [50, 60, 0.0019655582362449707], 34 | [60, 70, 0.0021172710822109477], 35 | [70, 80, 0.0011398025313718967], 36 | [80, 90, 0.0027750423615150576], 37 | [90, 125, 0.004237320542997875] 38 | ].sort((a, b) => b[2] - a[2]); 39 | 40 | exports.AVERAGE_SUCCESS_HIST_BUCKETS = [ 41 | // in ["a", "b", "c"] tuples, "c" part of the pixels have ciede2000 diff befween "a" (inc.) and "b" (exc.) 42 | [0, 5e-324, 0.9731894137187758], 43 | [5e-324, 0.2, 0.0007293636735490286], 44 | [0.2, 0.5, 0.0022950691047618353], 45 | [0.5, 1, 0.006358473318051954], 46 | [1, 1.6, 0.01174988831881833], 47 | [1.6, 1.73, 0.0016281902060853782], 48 | [1.73, 1.8765, 0.0007036292415788792], 49 | [1.8765, 2, 0.0019527786764358484], 50 | [2, 2.3, 0.0013931937419429665] 51 | ].sort((a, b) => b[2] - a[2]); 52 | -------------------------------------------------------------------------------- /benchmark/image-generator/contrast-to-reference-generator.js: -------------------------------------------------------------------------------- 1 | const {labToRgb} = require('./utils'); 2 | const {CONSTRAST_REFERENCE_COLOR} = require('./constants'); 3 | const {ImageGenerator} = require('./image-generator'); 4 | 5 | exports.ContrastToReferenceGenerator = class ContrastToReferenceGenerator extends ImageGenerator { 6 | constructor(width, height) { 7 | const colors = new Array(width * height).fill(labToRgb(CONSTRAST_REFERENCE_COLOR)); 8 | 9 | super(width, height, colors); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /benchmark/image-generator/fixed-visible-diff-amount-generator.js: -------------------------------------------------------------------------------- 1 | const {JND} = require('./constants'); 2 | const {HistCieDiffColorsGenerator} = require('./hist-cie-diff-colors-generator'); 3 | const {ImageGenerator} = require('./image-generator'); 4 | 5 | exports.FixedVisibleDiffAmountGenerator = class FixedVisibleDiffAmountGenerator extends ImageGenerator { 6 | constructor(width, height, diffPercent) { 7 | const colorsGenerator = new HistCieDiffColorsGenerator([ 8 | [0, Number.MIN_VALUE, 1 - diffPercent], 9 | [JND, 100, diffPercent] 10 | ]); 11 | const colors = new Array(width * height).fill(null).map(() => colorsGenerator.getRandomRgbColor()); 12 | 13 | super(width, height, colors); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /benchmark/image-generator/hist-cie-diff-colors-generator.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const colorDiff = require('color-diff'); 3 | const {REFERENCE_COLOR} = require('./constants'); 4 | const {labToRgb} = require('./utils'); 5 | 6 | const abMinValue = -38; 7 | const abMaxValue = 128; 8 | 9 | const lMinValue = 0; 10 | const lMaxValue = 100; 11 | 12 | const makeProbabilityDistribution = _.memoize((histBuckets) => { 13 | let sum = 0; 14 | 15 | return histBuckets.map(v => sum += v[2]); 16 | }); 17 | 18 | const createAllCiede2000LabDiffs = _.memoize((referenceLab) => { 19 | const ciede2000Diffs = []; 20 | const referenceRgb = labToRgb(referenceLab); 21 | 22 | for (let l = lMinValue; l <= lMaxValue; l++) { 23 | for (let ab = abMinValue; ab <= abMaxValue; ab++) { 24 | ciede2000Diffs.push({ 25 | diff: colorDiff.diff( 26 | {L: referenceLab[0], a: referenceLab[1], b: referenceLab[2]}, 27 | {L: l, a: ab, b: ab} 28 | ), 29 | lab: [l, ab, ab] 30 | }); 31 | } 32 | } 33 | 34 | // Multiple CIE-L*ab colors are being converted to same RGB colors 35 | return ciede2000Diffs.filter(({diff, lab}) => { 36 | const isZeroCiede2000Diff = diff === 0; 37 | const isSameRgbColor = _.isEqual(labToRgb(lab), referenceRgb); 38 | 39 | return isZeroCiede2000Diff === isSameRgbColor; 40 | }); 41 | }); 42 | 43 | const getSuitableBucketRgbColors = _.memoize((histBuckets, referenceLab) => { 44 | const ciede2000Diffs = createAllCiede2000LabDiffs(referenceLab); 45 | 46 | return histBuckets.map(bucket => { 47 | const averageCiede2000Diff = (bucket[0] + bucket[1]) / 2; 48 | const suitableLabColor = _.minBy(ciede2000Diffs, pair => Math.abs(pair.diff - averageCiede2000Diff)).lab; 49 | 50 | return labToRgb(suitableLabColor); 51 | }); 52 | }); 53 | 54 | exports.HistCieDiffColorsGenerator = class HistCieDiffColorsGenerator { 55 | constructor(histBuckets) { 56 | this._distribution = makeProbabilityDistribution(histBuckets); 57 | this._suitableBucketColors = getSuitableBucketRgbColors(histBuckets, REFERENCE_COLOR); 58 | } 59 | 60 | getRandomRgbColor() { 61 | const randomValue = Math.random(); 62 | 63 | // Shortcut to speed up execution 64 | if (randomValue < this._distribution[0]) { 65 | return this._suitableBucketColors[0]; 66 | } 67 | 68 | const bucketNumber = _.sortedIndex(this._distribution, randomValue); 69 | 70 | return this._suitableBucketColors[bucketNumber]; 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /benchmark/image-generator/image-generator.js: -------------------------------------------------------------------------------- 1 | const assert = require('node:assert'); 2 | const sharp = require('sharp'); 3 | const {CHANNELS_COUNT} = require('./constants'); 4 | 5 | exports.ImageGenerator = class ImageGenerator { 6 | constructor(width, height, rgbColors) { 7 | assert( 8 | width * height === rgbColors.length, 9 | `rgbColors array length missmatch.\nArray length: ${rgbColors.length}, w: ${width}, h: ${height}` 10 | ); 11 | 12 | const rawInput = new Uint8Array(width * height * CHANNELS_COUNT); 13 | 14 | let rawInputPosition = 0; 15 | 16 | for (const rgbColor of rgbColors) { 17 | for (const byte of rgbColor) { 18 | rawInput[rawInputPosition++] = byte; 19 | } 20 | } 21 | 22 | this._width = width; 23 | this._height = height; 24 | this._sharpImage = sharp(rawInput, { 25 | raw: { 26 | width, 27 | height, 28 | channels: CHANNELS_COUNT 29 | } 30 | }); 31 | } 32 | 33 | get width() { 34 | return this._width; 35 | } 36 | 37 | get height() { 38 | return this._height; 39 | } 40 | 41 | toPngBuffer() { 42 | return this._sharpImage.png().toBuffer({resolveWithObject: false}); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /benchmark/image-generator/index.js: -------------------------------------------------------------------------------- 1 | exports.WebAverageFailedGenerator = require('./web-average-failed-generator').WebAverageFailedGenerator; 2 | exports.WebAverageSuccessGenerator = require('./web-average-success-generator').WebAverageSuccessGenerator; 3 | exports.ReferenceGenerator = require('./reference-generator').ReferenceGenerator; 4 | exports.ContrastToReferenceGenerator = require('./contrast-to-reference-generator').ContrastToReferenceGenerator; 5 | exports.FixedVisibleDiffAmountGenerator = require('./fixed-visible-diff-amount-generator').FixedVisibleDiffAmountGenerator; 6 | -------------------------------------------------------------------------------- /benchmark/image-generator/reference-generator.js: -------------------------------------------------------------------------------- 1 | const {labToRgb} = require('./utils'); 2 | const {REFERENCE_COLOR} = require('./constants'); 3 | const {ImageGenerator} = require('./image-generator'); 4 | 5 | exports.ReferenceGenerator = class ReferenceGenerator extends ImageGenerator { 6 | constructor(width, height) { 7 | const colors = new Array(width * height).fill(labToRgb(REFERENCE_COLOR)); 8 | 9 | super(width, height, colors); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /benchmark/image-generator/utils.js: -------------------------------------------------------------------------------- 1 | const colorConvert = require('color-convert'); 2 | 3 | exports.labToRgb = function labToRgb(labColors) { 4 | return colorConvert.lab.rgb(labColors); 5 | }; 6 | -------------------------------------------------------------------------------- /benchmark/image-generator/web-average-failed-generator.js: -------------------------------------------------------------------------------- 1 | const {HistCieDiffColorsGenerator} = require('./hist-cie-diff-colors-generator'); 2 | const {AVERAGE_FAILED_HIST_BUCKETS} = require('./constants'); 3 | const {ImageGenerator} = require('./image-generator'); 4 | 5 | const AVERAGE_FAILED_WIDTH = 672; 6 | const AVERAGE_FAILED_HEIGHT = 623; 7 | 8 | exports.WebAverageFailedGenerator = class WebAverageFailedGenerator extends ImageGenerator { 9 | constructor(width = AVERAGE_FAILED_WIDTH, height = AVERAGE_FAILED_HEIGHT) { 10 | const colorsGenerator = new HistCieDiffColorsGenerator(AVERAGE_FAILED_HIST_BUCKETS); 11 | const colors = new Array(width * height).fill(null).map(() => colorsGenerator.getRandomRgbColor()); 12 | 13 | super(width, height, colors); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /benchmark/image-generator/web-average-success-generator.js: -------------------------------------------------------------------------------- 1 | const {HistCieDiffColorsGenerator} = require('./hist-cie-diff-colors-generator'); 2 | const {AVERAGE_SUCCESS_HIST_BUCKETS} = require('./constants'); 3 | const {ImageGenerator} = require('./image-generator'); 4 | 5 | const AVERAGE_FAILED_WIDTH = 656; 6 | const AVERAGE_FAILED_HEIGHT = 547; 7 | 8 | exports.WebAverageSuccessGenerator = class WebAverageSuccessGenerator extends ImageGenerator { 9 | constructor(width = AVERAGE_FAILED_WIDTH, height = AVERAGE_FAILED_HEIGHT) { 10 | const colorsGenerator = new HistCieDiffColorsGenerator(AVERAGE_SUCCESS_HIST_BUCKETS); 11 | const colors = new Array(width * height).fill(null).map(() => colorsGenerator.getRandomRgbColor()); 12 | 13 | super(width, height, colors); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const {Bench, hrtimeNow} = require('tinybench'); 3 | const {truncateNumber} = require('./utils'); 4 | const {createProgressBarFromBenchmark} = require('./progress-bar'); 5 | const hooks = require('./tasks/hooks'); 6 | const looksSame = require('./tasks/looks-same'); 7 | const pixelmatch = require('./tasks/pixelmatch'); 8 | const resemble = require('./tasks/resemble'); 9 | const blinkDiff = require('./tasks/blink-diff'); 10 | const {PACKAGES, CASES} = require('./constants'); 11 | 12 | const packageNameToMkCompareFnMap = { 13 | [PACKAGES.LOOKS_SAME]: looksSame.mkCompare, 14 | [PACKAGES.PIXELMATCH]: pixelmatch.mkCompare, 15 | [PACKAGES.RESEMBLE_JS]: resemble.mkCompare, 16 | [PACKAGES.BLINK_DIFF]: blinkDiff.mkCompare 17 | }; 18 | 19 | const mkTaskSet = (caseName, prepareFunc, taskCtx) => Object.keys(packageNameToMkCompareFnMap).map(packageName => ({ 20 | package: packageName, 21 | case: caseName, 22 | func: packageNameToMkCompareFnMap[packageName](taskCtx), 23 | prepareFunc 24 | })); 25 | 26 | // Images are passed into tasks via shared taskCtx 27 | const mkBenchmarkTasks = (taskCtx = {}) => [ 28 | ...mkTaskSet(CASES.WEB_AVG_DIFF, hooks.mkWebAverageFailedPair(taskCtx), taskCtx), 29 | ...mkTaskSet(CASES.WEB_AVG_SUCCESS, hooks.mkWebAverageSuccessPair(taskCtx), taskCtx), 30 | ...mkTaskSet(CASES.EQUAL_IMAGES, hooks.mkEqualPair(taskCtx, {width: 1000, height: 1000}), taskCtx), 31 | ...mkTaskSet(CASES.ONE_PERCENT_VISIBLE_DIFF, hooks.mkFixedVisibleDiffPercentPair(taskCtx, {width: 1000, height: 1000, diff: 0.01}), taskCtx), 32 | ...mkTaskSet(CASES.TEN_PERCENTS_VISIBLE_DIFF, hooks.mkFixedVisibleDiffPercentPair(taskCtx, {width: 1000, height: 1000, diff: 0.10}), taskCtx), 33 | ...mkTaskSet(CASES.FULL_MAX_DIFF, hooks.mkFullDiffPair(taskCtx, {width: 1000, height: 1000}), taskCtx), 34 | ...mkTaskSet(CASES.DEMONSTRATIVE_EXAMPLE, hooks.mkDemonstrativeExamplePair(taskCtx), taskCtx) 35 | ]; 36 | 37 | async function main() { 38 | const bench = new Bench({now: hrtimeNow, time: 0, warmupTime: 0, iterations: 16, warmupIterations: 4}); 39 | const benchmarkTasks = mkBenchmarkTasks(); 40 | 41 | benchmarkTasks.forEach(task => bench.add(`${task.package} / ${task.case}`, task.func, {beforeEach: task.prepareFunc})); 42 | 43 | createProgressBarFromBenchmark(bench); 44 | 45 | await bench.run(); 46 | 47 | const errorTasks = bench.tasks.filter(task => task.result.error); 48 | 49 | if (errorTasks.length) { 50 | errorTasks.forEach(({name, result}) => console.error('Task:', name, 'Error:', result.error)); 51 | } else { 52 | const tasks = benchmarkTasks.map((task, idx) => ({case: task.case, package: task.package, idx})); 53 | const caseGroupedTasks = _.groupBy(tasks, task => task.case); 54 | 55 | for (const caseName in caseGroupedTasks) { 56 | console.info(`Case: ${caseName}`); 57 | 58 | console.table(caseGroupedTasks[caseName].map(({package: packageName, idx}) => ({ 59 | 'Package': packageName, 60 | 'Avg (ms)': truncateNumber(bench.tasks[idx].result.latency.mean), 61 | 'p50 (ms)': truncateNumber(bench.tasks[idx].result.latency.p50), 62 | 'p99 (ms)': truncateNumber(bench.tasks[idx].result.latency.p99) 63 | }))); 64 | 65 | console.info('\n'); 66 | } 67 | } 68 | } 69 | 70 | main(); 71 | -------------------------------------------------------------------------------- /benchmark/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "looks-same-benchmarks", 3 | "scripts": { 4 | "start": "node index.js" 5 | }, 6 | "dependencies": { 7 | "blink-diff": "^1.0.13", 8 | "cli-progress": "^3.12.0", 9 | "color-convert": "^2.0.1", 10 | "color-diff": "^1.4.0", 11 | "lodash": "^4.17.21", 12 | "looks-same": "file:..", 13 | "pixelmatch": "^5.3.0", 14 | "pngjs": "^7.0.0", 15 | "resemblejs": "^5.0.0", 16 | "sharp": "^0.33.5", 17 | "tinybench": "^3.1.1" 18 | }, 19 | "devDependencies": { 20 | "@types/lodash": "^4.17.16" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /benchmark/progress-bar.js: -------------------------------------------------------------------------------- 1 | const cliProgress = require('cli-progress'); 2 | 3 | exports.createProgressBarFromBenchmark = benchmark => { 4 | const progressBar = new cliProgress.SingleBar({ 5 | format: ' [{bar}] | {value} / {total} | {status}', 6 | stopOnComplete: true, 7 | forceRedraw: true, 8 | autopadding: true, 9 | clearOnComplete: true 10 | }); 11 | 12 | const benchmarkTasksCount = benchmark.tasks.length + 1; // warmup; 13 | let doneTasksCount = 0; 14 | 15 | benchmark.addEventListener('warmup', () => { 16 | progressBar.start(benchmarkTasksCount, doneTasksCount, {status: 'Warming up the benchmark'}); 17 | }); 18 | 19 | benchmark.addEventListener('start', () => { 20 | doneTasksCount++; 21 | }); 22 | 23 | benchmark.tasks.forEach(task => { 24 | task.addEventListener('start', evt => { 25 | progressBar.update(doneTasksCount, {status: evt.task.name}); 26 | }); 27 | 28 | task.addEventListener('complete', () => { 29 | doneTasksCount++; 30 | }); 31 | }); 32 | 33 | benchmark.addEventListener('error', () => progressBar.stop()); 34 | benchmark.addEventListener('complete', () => progressBar.stop()); 35 | }; 36 | -------------------------------------------------------------------------------- /benchmark/results.md: -------------------------------------------------------------------------------- 1 | # Benchmark Results 2 | 3 | Execution times (in milliseconds) for image comparison packages across test cases. 4 | All values represent raw performance metrics (average, p50, p99). 5 | 6 | --- 7 | 8 | ## Case: web avg diff (672x623) 9 | | Package | Avg (ms) | p50 (ms) | p99 (ms) | 10 | |--------------|----------|----------|----------| 11 | | looks-same | 43.5 | 42 | 53.66 | 12 | | pixelmatch | 49.48 | 48.56 | 53.31 | 13 | | resemblejs | 47.01 | 46.63 | 51.89 | 14 | | blink-diff | 59.8 | 60.44 | 68.73 | 15 | 16 | ## Case: web avg success (656x547) 17 | | Package | Avg (ms) | p50 (ms) | p99 (ms) | 18 | |--------------|----------|----------|----------| 19 | | looks-same | 12.74 | 12.44 | 17.99 | 20 | | pixelmatch | 37.51 | 36.98 | 41.37 | 21 | | resemblejs | 28.74 | 28.27 | 33.31 | 22 | | blink-diff | 35.19 | 33.25 | 43.28 | 23 | 24 | ## Case: equal images (1000x1000) 25 | | Package | Avg (ms) | p50 (ms) | p99 (ms) | 26 | |--------------|----------|----------|----------| 27 | | looks-same | 0.04 | 0.04 | 0.05 | 28 | | pixelmatch | 45.39 | 44.95 | 49.18 | 29 | | resemblejs | 69.44 | 69.04 | 74.8 | 30 | | blink-diff | 88.65 | 88.14 | 98.4 | 31 | 32 | ## Case: 1% visible diff (1000x1000) 33 | | Package | Avg (ms) | p50 (ms) | p99 (ms) | 34 | |--------------|----------|----------|----------| 35 | | looks-same | 28.73 | 27.89 | 36.44 | 36 | | pixelmatch | 102.03 | 102.16 | 106.81 | 37 | | resemblejs | 72.63 | 72.17 | 82.85 | 38 | | blink-diff | 94.93 | 94.46 | 107.78 | 39 | 40 | ## Case: 10% visible diff (1000x1000) 41 | | Package | Avg (ms) | p50 (ms) | p99 (ms) | 42 | |--------------|----------|----------|----------| 43 | | looks-same | 33.66 | 33.68 | 37.44 | 44 | | pixelmatch | 101.23 | 101.02 | 105.67 | 45 | | resemblejs | 72.09 | 71.77 | 80.57 | 46 | | blink-diff | 94.52 | 94.13 | 105.07 | 47 | 48 | ## Case: full max diff (1000x1000) 49 | | Package | Avg (ms) | p50 (ms) | p99 (ms) | 50 | |--------------|----------|----------|----------| 51 | | looks-same | 467.33 | 463.05 | 491.6 | 52 | | pixelmatch | 148.59 | 148.39 | 153.3 | 53 | | resemblejs | 605.37 | 600.99 | 668.96 | 54 | | blink-diff | 685.83 | 683.26 | 741.43 | 55 | 56 | ## Case: demonstrative example visible 8% diff (896×784) 57 | | Package | Avg (ms) | p50 (ms) | p99 (ms) | 58 | |--------------|----------|----------|----------| 59 | | looks-same | 55.1 | 55.7 | 61.5 | 60 | | pixelmatch | 87.05 | 86.96 | 89.62 | 61 | | resemblejs | 80.05 | 79.72 | 87.71 | 62 | | blink-diff | 111.29 | 109.2 | 126.47 | 63 | -------------------------------------------------------------------------------- /benchmark/tasks/blink-diff.js: -------------------------------------------------------------------------------- 1 | const BlinkDiff = require('blink-diff'); 2 | 3 | exports.mkCompare = (taskCtx) => async () => { 4 | const diff = new BlinkDiff({ 5 | imageA: taskCtx.reference, 6 | imageB: taskCtx.pictureToCompare, 7 | thresholdType: BlinkDiff.THRESHOLD_PERCENT, 8 | threshold: 0.01 9 | }); 10 | 11 | await diff.runWithPromise(); 12 | }; 13 | -------------------------------------------------------------------------------- /benchmark/tasks/hooks/cache.js: -------------------------------------------------------------------------------- 1 | class HooksCache { 2 | constructor() { 3 | this._fnPerHooksCachedValuesPositions = new Map(); // hook -> fn -> position 4 | this._hooksCachedValues = new Map(); // hook -> array of saved values 5 | } 6 | 7 | _getHookCachedValues(hook) { 8 | if (!this._hooksCachedValues.has(hook)) { 9 | this._hooksCachedValues.set(hook, []); 10 | } 11 | 12 | return this._hooksCachedValues.get(hook); 13 | } 14 | 15 | _getFnCachedValuesPositions(hook) { 16 | if (!this._fnPerHooksCachedValuesPositions.has(hook)) { 17 | this._fnPerHooksCachedValuesPositions.set(hook, new Map()); 18 | } 19 | 20 | return this._fnPerHooksCachedValuesPositions.get(hook); 21 | } 22 | 23 | _getFnPosition(fnCachedValuesPositions, fn) { 24 | if (!fnCachedValuesPositions.has(fn)) { 25 | fnCachedValuesPositions.set(fn, 0); 26 | } 27 | 28 | return fnCachedValuesPositions.get(fn); 29 | } 30 | 31 | load(hookId, taskId) { 32 | if (!hookId || !taskId) { 33 | return null; 34 | } 35 | 36 | const hookCachedValues = this._getHookCachedValues(hookId); 37 | const fnCachedValuesPositions = this._getFnCachedValuesPositions(hookId); 38 | const functionPosition = this._getFnPosition(fnCachedValuesPositions, taskId); 39 | 40 | if (functionPosition < hookCachedValues.length) { 41 | fnCachedValuesPositions.set(taskId, functionPosition + 1); 42 | 43 | return hookCachedValues[functionPosition]; 44 | } 45 | 46 | return null; 47 | } 48 | 49 | save(hookId, taskId, ctx) { 50 | if (!hookId || !taskId) { 51 | return null; 52 | } 53 | 54 | const hookCachedValues = this._getHookCachedValues(hookId); 55 | const fnCachedValuesPositions = this._getFnCachedValuesPositions(hookId); 56 | const functionPosition = this._getFnPosition(fnCachedValuesPositions, taskId); 57 | 58 | hookCachedValues.push({...ctx}); 59 | fnCachedValuesPositions.set(taskId, functionPosition + 1); 60 | } 61 | } 62 | 63 | exports.hooksCache = new HooksCache(); 64 | 65 | // Subsequent tasks with same hook will use cached results 66 | // ensuring all tasks are run with the same input pictures 67 | exports.withHooksCache = function(taskCtx, cache, hook) { 68 | return async function() { 69 | const cachedValue = cache.load(hook.name, this.name); 70 | 71 | if (cachedValue) { 72 | Object.assign(taskCtx, cachedValue); 73 | return; 74 | } 75 | 76 | await hook.call(this); 77 | 78 | cache.save(hook.name, this.name, taskCtx); 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /benchmark/tasks/hooks/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const generators = require('../../image-generator'); 3 | const {hooksCache, withHooksCache} = require('./cache'); 4 | 5 | const addImagesToTaskCtx = async (taskCtx, referenceGenerator, pictureToCompareGenerator) => { 6 | const [reference, pictureToCompare] = await Promise.all([ 7 | referenceGenerator.toPngBuffer(), 8 | pictureToCompareGenerator.toPngBuffer() 9 | ]); 10 | 11 | taskCtx.reference = reference; 12 | taskCtx.pictureToCompare = pictureToCompare; 13 | }; 14 | 15 | exports.mkWebAverageFailedPair = (taskCtx, cache = hooksCache) => 16 | withHooksCache(taskCtx, cache, async function mkWebAverageFailedPair() { 17 | const averageFailedGenerator = new generators.WebAverageFailedGenerator(); 18 | const referenceGenerator = new generators.ReferenceGenerator(averageFailedGenerator.width, averageFailedGenerator.height); 19 | 20 | return addImagesToTaskCtx(taskCtx, referenceGenerator, averageFailedGenerator); 21 | }); 22 | 23 | exports.mkWebAverageSuccessPair = (taskCtx, cache = hooksCache) => 24 | withHooksCache(taskCtx, cache, async function mkWebAverageSuccessPair() { 25 | const averageFailedGenerator = new generators.WebAverageSuccessGenerator(); 26 | const referenceGenerator = new generators.ReferenceGenerator(averageFailedGenerator.width, averageFailedGenerator.height); 27 | 28 | return addImagesToTaskCtx(taskCtx, referenceGenerator, averageFailedGenerator); 29 | }); 30 | 31 | exports.mkEqualPair = (taskCtx, {width, height}, cache = hooksCache) => 32 | withHooksCache(taskCtx, cache, async function mkEqualPair() { 33 | const referenceGenerator = new generators.ReferenceGenerator(width, height); 34 | const alsoReferenceGenerator = new generators.ReferenceGenerator(width, height); 35 | 36 | return addImagesToTaskCtx(taskCtx, referenceGenerator, alsoReferenceGenerator); 37 | }); 38 | 39 | exports.mkFullDiffPair = (taskCtx, {width, height}, cache = hooksCache) => 40 | withHooksCache(taskCtx, cache, async function mkFullDiffPair() { 41 | const contrastReferenceGenerator = new generators.ContrastToReferenceGenerator(width, height); 42 | const referenceGenerator = new generators.ReferenceGenerator(width, height); 43 | 44 | return addImagesToTaskCtx(taskCtx, referenceGenerator, contrastReferenceGenerator); 45 | }); 46 | 47 | exports.mkFixedVisibleDiffPercentPair = (taskCtx, {width, height, diff}, cache = hooksCache) => 48 | withHooksCache(taskCtx, cache, async function mkFixedVisibleDiffPercentPair() { 49 | const referenceGenerator = new generators.ReferenceGenerator(width, height); 50 | const contrastReferenceGenerator = new generators.FixedVisibleDiffAmountGenerator(width, height, diff); 51 | 52 | return addImagesToTaskCtx(taskCtx, referenceGenerator, contrastReferenceGenerator); 53 | }); 54 | 55 | exports.mkDemonstrativeExamplePair = (taskCtx, cache = hooksCache) => 56 | withHooksCache(taskCtx, cache, async function mkDemonstrativeExamplePair() { 57 | const referencePath = require.resolve('../../fixtures/reference.png'); 58 | const actualPath = require.resolve('../../fixtures/actual.png'); 59 | 60 | const [reference, pictureToCompare] = await Promise.all([ 61 | fs.promises.readFile(referencePath), 62 | fs.promises.readFile(actualPath) 63 | ]); 64 | 65 | taskCtx.reference = reference; 66 | taskCtx.pictureToCompare = pictureToCompare; 67 | }); 68 | -------------------------------------------------------------------------------- /benchmark/tasks/looks-same.js: -------------------------------------------------------------------------------- 1 | const looksSame = require('looks-same'); 2 | 3 | exports.mkCompare = ( 4 | taskCtx, 5 | {strict = false, ignoreAntialiasing = true} = {}, 6 | ) => async () => { 7 | const result = await looksSame(taskCtx.reference, taskCtx.pictureToCompare, { 8 | createDiffImage: true, 9 | strict, 10 | ignoreAntialiasing, 11 | ignoreCaret: false, 12 | tolerance: 2.3, 13 | antialiasingTolerance: 6 14 | }); 15 | 16 | if (!result.equal) { 17 | await result.diffImage.createBuffer('png'); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /benchmark/tasks/pixelmatch.js: -------------------------------------------------------------------------------- 1 | const {PNG} = require('pngjs'); 2 | const pixelmatch = require('pixelmatch'); 3 | 4 | exports.mkCompare = (taskCtx) => async () => { 5 | const img1 = PNG.sync.read(taskCtx.reference); 6 | const img2 = PNG.sync.read(taskCtx.pictureToCompare); 7 | const {width, height} = img1; 8 | const diff = new PNG({width: img1.width, height: img1.height}); 9 | 10 | const isDiff = pixelmatch(img1.data, img2.data, diff.data, width, height, {threshold: 0.1}); 11 | 12 | if (isDiff) { 13 | PNG.sync.write(diff); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /benchmark/tasks/resemble.js: -------------------------------------------------------------------------------- 1 | const resemble = require('resemblejs'); 2 | 3 | exports.mkCompare = (taskCtx) => async () => { 4 | return new Promise((resolve) => { 5 | resemble(taskCtx.reference) 6 | .compareTo(taskCtx.pictureToCompare) 7 | .ignoreAntialiasing() 8 | .onComplete(resolve); 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /benchmark/utils.js: -------------------------------------------------------------------------------- 1 | exports.truncateNumber = function truncateNumber(value, precision = 2) { 2 | const multiplier = 10 ** precision; 3 | 4 | return Math.round(value * multiplier) / multiplier; 5 | }; 6 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for looks-same 5.0 2 | // Project: https://github.com/gemini-testing/looks-same/releases 3 | // Definitions by: xcatliu 4 | 5 | /// 6 | 7 | // https://stackoverflow.com/questions/44058101/typescript-declare-third-party-modules 8 | declare module looksSame { 9 | /** 10 | * coordinate bounds 11 | */ 12 | export interface CoordBounds { 13 | /** 14 | * X-coordinate of upper left corner 15 | */ 16 | left: number; 17 | /** 18 | * Y-coordinate of upper left corner 19 | */ 20 | top: number; 21 | /** 22 | * X-coordinate of bottom right corner 23 | */ 24 | right: number; 25 | /** 26 | * Y-coordinate of bottom right corner 27 | */ 28 | bottom: number; 29 | } 30 | 31 | /** 32 | * bounded image 33 | */ 34 | export interface BoundedImage { 35 | /** 36 | * image path or buffer 37 | */ 38 | source: string | Buffer; 39 | /** 40 | * bounding coordinates 41 | */ 42 | boundingBox: CoordBounds; 43 | } 44 | 45 | export interface DiffImage { 46 | /** 47 | * Width of the diff image 48 | */ 49 | width: number; 50 | /** 51 | * Height of the diff image 52 | */ 53 | height: number; 54 | /** 55 | * Save the diff image 56 | * Path should be specified with image extension 57 | */ 58 | save: (path: string) => Promise; 59 | /** 60 | * Create buffer of the diff image 61 | * If you need to save the image, consider using `save` method 62 | * Shoud not be mixed with `save` method 63 | */ 64 | createBuffer: (extension: "png" | "raw") => Promise; 65 | } 66 | 67 | interface LooksSameBaseResult { 68 | /** 69 | * true if images are equal, false - otherwise 70 | */ 71 | equal: boolean; 72 | /** 73 | * diff bounds for not equal images 74 | */ 75 | diffBounds: CoordBounds; 76 | /** 77 | * diff clusters for not equal images 78 | */ 79 | diffClusters: CoordBounds[]; 80 | } 81 | 82 | interface LooksSameCreateDiffImageResult extends LooksSameBaseResult { 83 | differentPixels: number; 84 | totalPixels: number; 85 | } 86 | 87 | interface LooksSameWithNoDiffResult extends LooksSameCreateDiffImageResult { 88 | equal: true; 89 | diffImage: null; 90 | } 91 | 92 | interface LooksSameWithExistingDiffResult extends LooksSameCreateDiffImageResult { 93 | equal: false; 94 | diffImage: DiffImage; 95 | } 96 | /** 97 | * The result obtained from the function. 98 | */ 99 | export type LooksSameResult = T extends true ? (LooksSameWithNoDiffResult | LooksSameWithExistingDiffResult) : LooksSameBaseResult; 100 | 101 | /** 102 | * The options passed to looksSame function 103 | */ 104 | export interface LooksSameOptions { 105 | /** 106 | * By default, it will detect only noticeable differences. If you wish to detect any difference, use strict options. 107 | */ 108 | strict?: boolean; 109 | /** 110 | * You can also adjust the ΔE value that will be treated as error in non-strict mode. 111 | */ 112 | tolerance?: number; 113 | /** 114 | * Some devices can have different proportion between physical and logical screen resolutions also known as pixel ratio. 115 | * Default value for this proportion is 1. 116 | * This param also affects the comparison result, so it can be set manually with pixelRatio option. 117 | */ 118 | pixelRatio?: number; 119 | /** 120 | * Text caret in text input elements it is a pain for visual regression tasks, because it is always blinks. 121 | * These diffs will be ignored by default. You can use `ignoreCaret` option with `false` value to disable ignoring such diffs. 122 | * In that way text caret will be marked as diffs. 123 | */ 124 | ignoreCaret?: boolean; 125 | /** 126 | * Some images has difference while comparing because of antialiasing. 127 | * These diffs will be ignored by default. You can use ignoreAntialiasing option with false value to disable ignoring such diffs. 128 | * In that way antialiased pixels will be marked as diffs. 129 | */ 130 | ignoreAntialiasing?: boolean; 131 | /** 132 | * Sometimes the antialiasing algorithm can work incorrectly due to some features of the browser rendering engine. 133 | * Use the option antialiasingTolerance to make the algorithm less strict. 134 | * With this option you can specify the minimum difference in brightness (zero by default) 135 | * between the darkest/lightest pixel (which is adjacent to the antialiasing pixel) and theirs adjacent pixels. 136 | * 137 | * We recommend that you don't increase this value above 10. If you need to increase more than 10 then this is definitely not antialiasing. 138 | */ 139 | antialiasingTolerance?: number; 140 | /** 141 | * Responsible for diff area which will be returned when comparing images. 142 | * Diff bounds will contain the whole diff if stopOnFirstFail is false and only first diff pixel - otherwise. 143 | */ 144 | stopOnFirstFail?: boolean; 145 | /** 146 | * Responsible for diff bounds clustering 147 | */ 148 | shouldCluster?: boolean; 149 | /** 150 | * Radius for every diff cluster 151 | */ 152 | clustersSize?: number; 153 | /** 154 | * If you need both to compare images and create diff image 155 | */ 156 | createDiffImage?: boolean 157 | } 158 | 159 | export interface GetDiffAreaOptions { 160 | /** 161 | * Strict comparsion 162 | */ 163 | strict?: boolean; 164 | /** 165 | * ΔE value that will be treated as error in non-strict mode 166 | */ 167 | tolerance?: number; 168 | /** 169 | * Some devices can have different proportion between physical and logical screen resolutions also known as pixel ratio. 170 | */ 171 | pixelRatio?: number; 172 | /** 173 | * Ability to ignore text caret 174 | */ 175 | ignoreCaret?: boolean; 176 | /** 177 | * Ability to ignore antialiasing 178 | */ 179 | ignoreAntialiasing?: boolean; 180 | /** 181 | * Makes the search algorithm of the antialiasing less strict 182 | */ 183 | antialiasingTolerance?: number; 184 | /** 185 | * Responsible for diff area which will be returned when comparing images. 186 | */ 187 | stopOnFirstFail?: boolean; 188 | /** 189 | * Responsible for diff bounds clustering 190 | */ 191 | shouldCluster?: boolean; 192 | /** 193 | * Radius for every diff cluster 194 | */ 195 | clustersSize?: number; 196 | } 197 | 198 | /** 199 | * The options passed to looksSame.createDiff function without diff 200 | */ 201 | export interface CreateDiffAsBufferOptions { 202 | /** 203 | * The baseline image 204 | */ 205 | reference: string | Buffer | BoundedImage; 206 | /** 207 | * The current image 208 | */ 209 | current: string | Buffer | BoundedImage; 210 | /** 211 | * Color to highlight the differences 212 | * e.g. '#ff00ff' 213 | */ 214 | highlightColor: string; 215 | /** 216 | * Strict comparsion 217 | */ 218 | strict?: boolean; 219 | /** 220 | * ΔE value that will be treated as error in non-strict mode 221 | */ 222 | tolerance?: number; 223 | /** 224 | * Makes the search algorithm of the antialiasing less strict 225 | */ 226 | antialiasingTolerance?: number; 227 | /** 228 | * Ability to ignore antialiasing 229 | */ 230 | ignoreAntialiasing?: boolean; 231 | /** 232 | * Ability to ignore text caret 233 | */ 234 | ignoreCaret?: boolean; 235 | } 236 | 237 | /** 238 | * The options passed to looksSame.createDiff function 239 | */ 240 | export interface CreateDiffOptions extends CreateDiffAsBufferOptions { 241 | /** 242 | * The diff image path to store 243 | */ 244 | diff: string; 245 | } 246 | 247 | /** 248 | * Pass to looksSame.colors function 249 | */ 250 | export interface Color { 251 | /** 252 | * Red 253 | */ 254 | R: number; 255 | /** 256 | * Green 257 | */ 258 | G: number; 259 | /** 260 | * Blue 261 | */ 262 | B: number; 263 | } 264 | 265 | export function getDiffArea( 266 | image1: string | Buffer | BoundedImage, 267 | image2: string | Buffer | BoundedImage 268 | ): Promise; 269 | export function getDiffArea( 270 | image1: string | Buffer | BoundedImage, 271 | image2: string | Buffer | BoundedImage, 272 | opts: GetDiffAreaOptions 273 | ): Promise; 274 | 275 | export function createDiff(options: CreateDiffOptions): Promise; 276 | export function createDiff(options: CreateDiffAsBufferOptions): Promise; 277 | 278 | /** 279 | * Compare two colors 280 | * @param color1 The first color 281 | * @param color2 The second color 282 | * @param options The options passed to looksSame.colors function 283 | */ 284 | export function colors(color1: Color, color2: Color): boolean; 285 | export function colors(color1: Color, color2: Color, options: { tolerance: number }): boolean; 286 | } 287 | 288 | /** 289 | * Compare two images with options 290 | * @param image1 The first image 291 | * @param image2 The second image 292 | * @param options The options passed to looksSame function 293 | */ 294 | declare function looksSame( 295 | image1: string | Buffer | looksSame.BoundedImage, 296 | image2: string | Buffer | looksSame.BoundedImage, 297 | options?: looksSame.LooksSameOptions & { createDiffImage?: false }, 298 | ): Promise>; 299 | 300 | declare function looksSame( 301 | image1: string | Buffer | looksSame.BoundedImage, 302 | image2: string | Buffer | looksSame.BoundedImage, 303 | options: looksSame.LooksSameOptions & { createDiffImage: true }, 304 | ): Promise>; 305 | 306 | /** 307 | * Node.js library for comparing PNG-images, taking into account human color perception. 308 | * It is created specially for the needs of visual regression testing for gemini utility, but can be used for other purposes. 309 | */ 310 | export = looksSame; 311 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const colorDiff = require('color-diff'); 5 | const img = require('./lib/image'); 6 | const areColorsSame = require('./lib/same-colors'); 7 | const AntialiasingComparator = require('./lib/antialiasing-comparator'); 8 | const IgnoreCaretComparator = require('./lib/ignore-caret-comparator'); 9 | const DiffArea = require('./lib/diff-area'); 10 | const utils = require('./lib/utils'); 11 | const {JND} = require('./lib/constants'); 12 | 13 | const makeAntialiasingComparator = (comparator, img1, img2, opts) => { 14 | const antialiasingComparator = new AntialiasingComparator(comparator, img1, img2, opts); 15 | return (data) => antialiasingComparator.compare(data); 16 | }; 17 | 18 | const makeNoCaretColorComparator = (comparator, pixelRatio) => { 19 | const caretComparator = new IgnoreCaretComparator(comparator, pixelRatio); 20 | return (data) => caretComparator.compare(data); 21 | }; 22 | 23 | function makeCIEDE2000Comparator(tolerance) { 24 | const upperBound = tolerance * 6.2; // cie76 <= 6.2 * ciede2000 25 | const lowerBound = tolerance * 0.695; // cie76 >= 0.695 * ciede2000 26 | 27 | return function doColorsLookSame(data) { 28 | if (areColorsSame(data)) { 29 | return true; 30 | } 31 | /*jshint camelcase:false*/ 32 | const lab1 = colorDiff.rgb_to_lab(data.color1); 33 | const lab2 = colorDiff.rgb_to_lab(data.color2); 34 | 35 | const cie76 = Math.sqrt( 36 | (lab1.L - lab2.L) * (lab1.L - lab2.L) + 37 | (lab1.a - lab2.a) * (lab1.a - lab2.a) + 38 | (lab1.b - lab2.b) * (lab1.b - lab2.b) 39 | ); 40 | 41 | if (cie76 >= upperBound) { 42 | return false; 43 | } 44 | 45 | if (cie76 <= lowerBound) { 46 | return true; 47 | } 48 | 49 | return colorDiff.diff(lab1, lab2) < tolerance; 50 | }; 51 | } 52 | 53 | const createComparator = (img1, img2, opts) => { 54 | let comparator = opts.strict ? areColorsSame : makeCIEDE2000Comparator(opts.tolerance); 55 | 56 | if (opts.ignoreAntialiasing) { 57 | comparator = makeAntialiasingComparator(comparator, img1, img2, opts); 58 | } 59 | 60 | if (opts.ignoreCaret) { 61 | comparator = makeNoCaretColorComparator(comparator, opts.pixelRatio); 62 | } 63 | 64 | return comparator; 65 | }; 66 | 67 | const iterateRect = async (width, height, callback) => { 68 | return new Promise((resolve) => { 69 | const processRow = (y) => { 70 | setImmediate(() => { 71 | for (let x = 0; x < width; x++) { 72 | callback(x, y); 73 | } 74 | 75 | y++; 76 | 77 | if (y < height) { 78 | processRow(y); 79 | } else { 80 | resolve(); 81 | } 82 | }); 83 | }; 84 | 85 | processRow(0); 86 | }); 87 | }; 88 | 89 | const buildDiffImage = async (img1, img2, options) => { 90 | const width = Math.max(img1.width, img2.width); 91 | const height = Math.max(img1.height, img2.height); 92 | const minWidth = Math.min(img1.width, img2.width); 93 | const minHeight = Math.min(img1.height, img2.height); 94 | 95 | const highlightColor = options.highlightColor; 96 | const resultBuffer = Buffer.alloc(width * height * 3); 97 | 98 | const setPixel = (buf, x, y, {R, G, B}) => { 99 | const pixelInd = (y * width + x) * 3; 100 | buf[pixelInd] = R; 101 | buf[pixelInd + 1] = G; 102 | buf[pixelInd + 2] = B; 103 | }; 104 | 105 | await iterateRect(width, height, (x, y) => { 106 | if (x >= minWidth || y >= minHeight) { 107 | setPixel(resultBuffer, x, y, highlightColor); 108 | return; 109 | } 110 | 111 | const color1 = img1.getPixel(x, y); 112 | const color2 = img2.getPixel(x, y); 113 | 114 | if (!options.comparator({color1, color2, img1, img2, x, y, width, height, minWidth, minHeight})) { 115 | setPixel(resultBuffer, x, y, highlightColor); 116 | } else { 117 | setPixel(resultBuffer, x, y, color1); 118 | } 119 | }); 120 | 121 | return img.fromBuffer(resultBuffer, {raw: {width, height, channels: 3}}); 122 | }; 123 | 124 | const getToleranceFromOpts = (opts) => { 125 | if (!_.hasIn(opts, 'tolerance')) { 126 | return JND; 127 | } 128 | 129 | if (opts.strict) { 130 | throw new TypeError('Unable to use "strict" and "tolerance" options together'); 131 | } 132 | 133 | return opts.tolerance; 134 | }; 135 | 136 | const prepareOpts = (opts) => { 137 | opts = opts || {}; 138 | opts.tolerance = getToleranceFromOpts(opts); 139 | 140 | return _.defaults(opts, { 141 | ignoreCaret: true, 142 | ignoreAntialiasing: true, 143 | antialiasingTolerance: 0 144 | }); 145 | }; 146 | 147 | const getMaxDiffBounds = (first, second) => { 148 | const {x: left, y: top} = first.getActualCoord(0, 0); 149 | 150 | return { 151 | left, 152 | top, 153 | right: left + Math.max(first.width, second.width) - 1, 154 | bottom: top + Math.max(first.height, second.height) - 1 155 | }; 156 | }; 157 | 158 | module.exports = exports = async function looksSame(image1, image2, opts = {}) { 159 | opts = prepareOpts(opts); 160 | [image1, image2] = utils.formatImages(image1, image2); 161 | 162 | const {first, second} = await utils.readPair(image1, image2, utils.readBufferCb); 163 | const areBuffersEqual = utils.areBuffersEqual(first, second); 164 | 165 | const refImg = {size: {width: first.width, height: first.height}}; 166 | const metaInfo = {refImg}; 167 | 168 | if (areBuffersEqual) { 169 | const diffBounds = (new DiffArea()).area; 170 | 171 | return {equal: true, metaInfo, diffBounds, diffClusters: [diffBounds], diffImage: null}; 172 | } 173 | 174 | if (!opts.createDiffImage && (first.width !== second.width || first.height !== second.height)) { 175 | const diffBounds = getMaxDiffBounds(first, second); 176 | 177 | return {equal: false, metaInfo, diffBounds, diffClusters: [diffBounds]}; 178 | } 179 | 180 | const {first: img1, second: img2} = await utils.readPair( 181 | {...image1, source: first.buffer}, 182 | {...image2, source: second.buffer}, 183 | utils.readImgCb 184 | ); 185 | 186 | const comparator = createComparator(img1, img2, opts); 187 | const {stopOnFirstFail, shouldCluster, clustersSize, createDiffImage, highlightColor} = opts; 188 | 189 | if (createDiffImage) { 190 | return utils.calcDiffImage(img1, img2, comparator, {highlightColor, shouldCluster, clustersSize}); 191 | } 192 | 193 | const {diffArea, diffClusters} = await utils.getDiffPixelsCoords(img1, img2, comparator, {stopOnFirstFail, shouldCluster, clustersSize}); 194 | const diffBounds = diffArea.area; 195 | const equal = diffArea.isEmpty(); 196 | 197 | return {equal, metaInfo, diffBounds, diffClusters}; 198 | }; 199 | 200 | exports.getDiffArea = async function(image1, image2, opts = {}) { 201 | opts = prepareOpts(opts); 202 | [image1, image2] = utils.formatImages(image1, image2); 203 | 204 | const {first, second} = await utils.readPair(image1, image2); 205 | 206 | if (first.width !== second.width || first.height !== second.height) { 207 | return getMaxDiffBounds(first, second); 208 | } 209 | 210 | const comparator = createComparator(first, second, opts); 211 | 212 | const {diffArea} = await utils.getDiffPixelsCoords(first, second, comparator, opts); 213 | 214 | if (diffArea.isEmpty()) { 215 | return null; 216 | } 217 | 218 | return diffArea.area; 219 | }; 220 | 221 | exports.createDiff = async function saveDiff(opts) { 222 | opts = prepareOpts(opts); 223 | opts.extension = opts.extension || 'png'; 224 | 225 | const [image1, image2] = utils.formatImages(opts.reference, opts.current); 226 | const {first, second} = await utils.readPair(image1, image2); 227 | const diffImage = await buildDiffImage(first, second, { 228 | highlightColor: utils.parseColorString(opts.highlightColor), 229 | comparator: createComparator(first, second, opts) 230 | }); 231 | 232 | return opts.diff === undefined 233 | ? diffImage.createBuffer(opts.extension) 234 | : diffImage.save(opts.diff); 235 | }; 236 | 237 | exports.colors = (color1, color2, opts) => { 238 | opts = opts || {}; 239 | 240 | if (opts.tolerance === undefined) { 241 | opts.tolerance = JND; 242 | } 243 | 244 | const comparator = makeCIEDE2000Comparator(opts.tolerance); 245 | 246 | return comparator({color1, color2}); 247 | }; 248 | -------------------------------------------------------------------------------- /lib/antialiasing-comparator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Anti-aliased pixel detector 5 | * @see http://www.eejournal.ktu.lt/index.php/elt/article/view/10058/5000 6 | */ 7 | 8 | const DEFAULT_BRIGHTNESS_TOLERANCE = 0; 9 | 10 | module.exports = class AntialiasingComparator { 11 | constructor(baseComparator, img1, img2, {antialiasingTolerance = 0}) { 12 | this._baseComparator = baseComparator; 13 | this._img1 = img1; 14 | this._img2 = img2; 15 | this._brightnessTolerance = antialiasingTolerance; // used only when comparing the darkest and the brightest pixels 16 | } 17 | 18 | compare(data) { 19 | return this._baseComparator(data) || this._checkIsAntialiased(data); 20 | } 21 | 22 | _checkIsAntialiased(data) { 23 | return this._isAntialiased(this._img2, data.x, data.y, data, this._img1) 24 | || this._isAntialiased(this._img1, data.x, data.y, data, this._img2); 25 | } 26 | 27 | _isAntialiased(img1, x1, y1, data, img2) { 28 | const color1 = img1.getPixel(x1, y1); 29 | const width = data.width; 30 | const height = data.height; 31 | const x0 = Math.max(x1 - 1, 0); 32 | const y0 = Math.max(y1 - 1, 0); 33 | const x2 = Math.min(x1 + 1, width - 1); 34 | const y2 = Math.min(y1 + 1, height - 1); 35 | 36 | const checkExtremePixels = !img2; 37 | const brightnessTolerance = checkExtremePixels ? this._brightnessTolerance : DEFAULT_BRIGHTNESS_TOLERANCE; 38 | 39 | let zeroes = 0; 40 | let positives = 0; 41 | let negatives = 0; 42 | let min = 0; 43 | let max = 0; 44 | let minX, minY, maxX, maxY; 45 | 46 | for (let y = y0; y <= y2; y++) { 47 | for (let x = x0; x <= x2; x++) { 48 | if (x === x1 && y === y1) { 49 | continue; 50 | } 51 | 52 | // brightness delta between the center pixel and adjacent one 53 | const delta = this._brightnessDelta(img1.getPixel(x, y), color1); 54 | 55 | // count the number of equal, darker and brighter adjacent pixels 56 | if (Math.abs(delta) <= brightnessTolerance) { 57 | zeroes++; 58 | } else if (delta > brightnessTolerance) { 59 | positives++; 60 | } else { 61 | negatives++; 62 | } 63 | 64 | // if found more than 2 equal siblings, it's definitely not anti-aliasing 65 | if (zeroes > 2) { 66 | return false; 67 | } 68 | 69 | if (checkExtremePixels) { 70 | continue; 71 | } 72 | 73 | // remember the darkest pixel 74 | if (delta < min) { 75 | min = delta; 76 | minX = x; 77 | minY = y; 78 | } 79 | // remember the brightest pixel 80 | if (delta > max) { 81 | max = delta; 82 | maxX = x; 83 | maxY = y; 84 | } 85 | } 86 | } 87 | 88 | if (checkExtremePixels) { 89 | return true; 90 | } 91 | 92 | // if there are no both darker and brighter pixels among siblings, it's not anti-aliasing 93 | if (negatives === 0 || positives === 0) { 94 | return false; 95 | } 96 | 97 | // if either the darkest or the brightest pixel has more than 2 equal siblings in both images 98 | // (definitely not anti-aliased), this pixel is anti-aliased 99 | return (!this._isAntialiased(img1, minX, minY, data) && !this._isAntialiased(img2, minX, minY, data)) || 100 | (!this._isAntialiased(img1, maxX, maxY, data) && !this._isAntialiased(img2, maxX, maxY, data)); 101 | } 102 | 103 | _brightnessDelta(color1, color2) { 104 | return rgb2y(color1.R, color1.G, color1.B) - rgb2y(color2.R, color2.G, color2.B); 105 | } 106 | }; 107 | 108 | // gamma-corrected luminance of a color (YIQ NTSC transmission color space) 109 | // see https://www.academia.edu/8200524/DIGITAL_IMAGE_PROCESSING_Digital_Image_Processing_PIKS_Inside_Third_Edition 110 | function rgb2y(r, g, b) { 111 | return r * 0.29889531 + g * 0.58662247 + b * 0.11448223; 112 | } 113 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | JND: 2.3, // Just noticeable difference if ciede2000 >= JND then colors difference is noticeable by human eye 5 | REQUIRED_IMAGE_FIELDS: ['source', 'boundingBox'], 6 | REQUIRED_BOUNDING_BOX_FIELDS: ['left', 'top', 'right', 'bottom'], 7 | CLUSTERS_SIZE: 10, 8 | DIFF_IMAGE_CHANNELS: 3 9 | }; 10 | -------------------------------------------------------------------------------- /lib/diff-area.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = class DiffArea { 4 | static create() { 5 | return new DiffArea(); 6 | } 7 | 8 | constructor() { 9 | this._diffArea = {left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity}; 10 | this._updated = false; 11 | } 12 | 13 | update(x, y) { 14 | const {left, top, right, bottom} = this._diffArea; 15 | 16 | this._diffArea = { 17 | left: Math.min(left, x), 18 | top: Math.min(top, y), 19 | right: Math.max(right, x), 20 | bottom: Math.max(bottom, y) 21 | }; 22 | this._updated = true; 23 | 24 | return this; 25 | } 26 | 27 | isPointInArea(x, y, radius) { 28 | const {left, top, right, bottom} = this._diffArea; 29 | 30 | return x >= (left - radius) && x <= (right + radius) && y >= (top - radius) && y <= (bottom + radius); 31 | } 32 | 33 | isEmpty() { 34 | return !this._updated; 35 | } 36 | 37 | get area() { 38 | return this._diffArea; 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /lib/diff-clusters/clusters-joiner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const DiffArea = require('../diff-area'); 4 | const jsgraphs = require('js-graph-algorithms'); 5 | 6 | const hasOverlap = (cluster1, cluster2) => { 7 | if (cluster1.left > cluster2.right || cluster2.left > cluster1.right) { 8 | return false; 9 | } 10 | 11 | if (cluster1.bottom < cluster2.top || cluster2.bottom < cluster1.top) { 12 | return false; 13 | } 14 | 15 | return true; 16 | }; 17 | 18 | const getConnectedComponents = (clusters) => { 19 | const graph = new jsgraphs.Graph(clusters.length); 20 | 21 | clusters.forEach((c1, i) => { 22 | clusters.forEach((c2, j) => { 23 | if (i !== j && hasOverlap(c1.area, c2.area)) { 24 | graph.addEdge(i, j); 25 | } 26 | }); 27 | }); 28 | 29 | return new jsgraphs.ConnectedComponents(graph); 30 | }; 31 | 32 | exports.join = (clusters) => { 33 | const connectedComponents = getConnectedComponents(clusters); 34 | 35 | return connectedComponents.id.reduce((acc, clusterId, i) => { 36 | const {left, top, right, bottom} = clusters[i].area; 37 | if (!acc[clusterId]) { 38 | acc[clusterId] = DiffArea.create(); 39 | } 40 | 41 | acc[clusterId] 42 | .update(left, top) 43 | .update(right, bottom); 44 | 45 | return acc; 46 | }, []); 47 | }; 48 | -------------------------------------------------------------------------------- /lib/diff-clusters/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const DiffArea = require('../diff-area'); 4 | const {CLUSTERS_SIZE} = require('../constants'); 5 | const clustersJoiner = require('./clusters-joiner'); 6 | 7 | module.exports = class DiffClusters { 8 | constructor(clustersSize) { 9 | this._clustersSize = clustersSize || CLUSTERS_SIZE; 10 | this._clusters = []; 11 | } 12 | 13 | update(x, y) { 14 | if (!this._clusters.length) { 15 | this._clusters.push(DiffArea.create().update(x, y)); 16 | 17 | return; 18 | } 19 | 20 | this._joinToClusters(x, y); 21 | 22 | return this; 23 | } 24 | 25 | _joinToClusters(x, y) { 26 | const pointCluster = this._clusters.find((c) => c.isPointInArea(x, y, this._clustersSize)); 27 | 28 | if (!pointCluster) { 29 | this._clusters.push(DiffArea.create().update(x, y)); 30 | 31 | return; 32 | } 33 | 34 | pointCluster.update(x, y); 35 | } 36 | 37 | get clusters() { 38 | return clustersJoiner.join(this._clusters).map(c => c.area); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /lib/ignore-caret-comparator/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const STATES = { 4 | InitState: require('./states/init'), 5 | CaretDetectedState: require('./states/caret-detected') 6 | }; 7 | 8 | module.exports = class IgnoreCaretComparator { 9 | constructor(baseComparator, pixelRatio) { 10 | this.pixelRatio = pixelRatio ? Math.floor(pixelRatio) : 1; 11 | this.caretTopLeft = null; 12 | this.caretBottomRight = null; 13 | this._baseComparator = baseComparator; 14 | 15 | this.switchState('InitState'); 16 | } 17 | 18 | /** 19 | * Compare pixels for current active comparator state 20 | * @param {Object} data 21 | * @param {Object} data.color1 22 | * @param {Object} data.color2 23 | * @param {Number} data.x coordinate 24 | * @param {Number} data.y coordinate 25 | * @returns {boolean} 26 | */ 27 | compare(data) { 28 | return this._baseComparator(data) || this._checkIsCaret(data); 29 | } 30 | 31 | _checkIsCaret(data) { 32 | return this._state.validate(data); 33 | } 34 | 35 | switchState(stateName) { 36 | this._state = new STATES[stateName](this); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /lib/ignore-caret-comparator/states/caret-detected.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const State = require('./state'); 4 | 5 | module.exports = class CaretDetectedState extends State { 6 | validate(point) { 7 | return this._isInsideCaret(point); 8 | } 9 | 10 | _isInsideCaret(point) { 11 | return point.x >= this.caretTopLeft.x && point.x <= this.caretBottomRight.x 12 | && point.y >= this.caretTopLeft.y && point.y <= this.caretBottomRight.y; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /lib/ignore-caret-comparator/states/init.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const State = require('./state'); 4 | const areColorsSame = require('../../same-colors'); 5 | 6 | module.exports = class InitState extends State { 7 | validate(data) { 8 | const lastCaretPoint = this._getLastCaretPoint(data); 9 | 10 | if (!this._looksLikeCaret(data, lastCaretPoint)) { 11 | return false; 12 | } 13 | 14 | this.caretTopLeft = data; 15 | this.caretBottomRight = lastCaretPoint; 16 | 17 | this.switchState('CaretDetectedState'); 18 | 19 | return true; 20 | } 21 | 22 | _getLastCaretPoint(data) { 23 | let currPoint = data; 24 | 25 | /* eslint-disable-next-line no-constant-condition */ 26 | while (true) { 27 | const nextPoint = this._getNextCaretPoint(data, currPoint); 28 | 29 | if (this._isPointOutsideImages(nextPoint, data) || this._areColorsSame(nextPoint, data)) { 30 | return currPoint; 31 | } 32 | currPoint = nextPoint; 33 | } 34 | } 35 | 36 | _isPointOutsideImages(point, data) { 37 | return point.x >= data.minWidth || point.y >= data.minHeight; 38 | } 39 | 40 | _areColorsSame(point, data) { 41 | const color1 = data.img1.getPixel(point.x, point.y); 42 | const color2 = data.img2.getPixel(point.x, point.y); 43 | 44 | return areColorsSame({color1, color2}); 45 | } 46 | 47 | _getNextCaretPoint(firstCaretPoint, currPoint) { 48 | const nextX = currPoint.x + 1; 49 | 50 | return nextX < firstCaretPoint.x + this.pixelRatio 51 | ? {x: nextX, y: currPoint.y} 52 | : {x: firstCaretPoint.x, y: currPoint.y + 1}; 53 | } 54 | 55 | _looksLikeCaret(firstCaretPoint, lastCaretPoint) { 56 | return this._caretHeight(firstCaretPoint, lastCaretPoint) > 1 57 | && this._caretWidth(firstCaretPoint, lastCaretPoint) === this.pixelRatio; 58 | } 59 | 60 | _caretHeight(firstCaretPoint, lastCaretPoint) { 61 | return (lastCaretPoint.y - firstCaretPoint.y) + 1; 62 | } 63 | 64 | _caretWidth(firstCaretPoint, lastCaretPoint) { 65 | return (lastCaretPoint.x - firstCaretPoint.x) + 1; 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /lib/ignore-caret-comparator/states/state.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = class State { 4 | constructor(comparator) { 5 | this._ctx = comparator; 6 | } 7 | 8 | validate() { 9 | throw new Error('Not implemented'); 10 | } 11 | 12 | switchState(state) { 13 | this._ctx.switchState(state); 14 | } 15 | 16 | get pixelRatio() { 17 | return this._ctx.pixelRatio; 18 | } 19 | 20 | get caretTopLeft() { 21 | return this._ctx.caretTopLeft; 22 | } 23 | 24 | set caretTopLeft(point) { 25 | this._ctx.caretTopLeft = point; 26 | } 27 | 28 | get caretBottomRight() { 29 | return this._ctx.caretBottomRight; 30 | } 31 | 32 | set caretBottomRight(point) { 33 | this._ctx.caretBottomRight = point; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /lib/image-base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = class ImageBase { 4 | static create(...args) { 5 | return new this(...args); 6 | } 7 | 8 | getActualCoord() { 9 | throw new Error('Not implemented'); 10 | } 11 | 12 | get width() { 13 | throw new Error('Not implemented'); 14 | } 15 | 16 | get height() { 17 | throw new Error('Not implemented'); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /lib/image/bounded-image.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Image = require('./image'); 4 | 5 | module.exports = class BoundedImage extends Image { 6 | constructor(img, boundingBox) { 7 | super(img); 8 | 9 | this._boundingBox = boundingBox; 10 | } 11 | 12 | getPixel(x, y) { 13 | const {x: actX, y: actY} = this.getActualCoord(x, y); 14 | 15 | return super.getPixel(actX, actY); 16 | } 17 | 18 | getActualCoord(x, y) { 19 | return {x: x + this._boundingBox.left, y: y + this._boundingBox.top}; 20 | } 21 | 22 | get width() { 23 | return this._boundingBox.right - this._boundingBox.left + 1; 24 | } 25 | 26 | get height() { 27 | return this._boundingBox.bottom - this._boundingBox.top + 1; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /lib/image/image.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ImageBase = require('../image-base'); 4 | 5 | module.exports = class Image extends ImageBase { 6 | constructor(img) { 7 | super(); 8 | 9 | this._img = img; 10 | } 11 | 12 | async init() { 13 | const {data, info} = await this._img.raw().toBuffer({resolveWithObject: true}); 14 | 15 | this._buffer = data; 16 | this._width = info.width; 17 | this._height = info.height; 18 | this._channels = info.channels; 19 | } 20 | 21 | async initMeta() { 22 | const {width, height, channels} = await this._img.metadata(); 23 | 24 | this._width = width; 25 | this._height = height; 26 | this._channels = channels; 27 | } 28 | 29 | getPixel(x, y) { 30 | const idx = this._getIdx(x, y); 31 | return { 32 | R: this._buffer[idx], 33 | G: this._buffer[idx + 1], 34 | B: this._buffer[idx + 2] 35 | }; 36 | } 37 | 38 | _getIdx(x, y) { 39 | return (this._width * y + x) * this._channels; 40 | } 41 | 42 | async save(path) { 43 | return this._img.toFile(path); 44 | } 45 | 46 | async createBuffer(extension) { 47 | return this._img.toFormat(extension).toBuffer(); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /lib/image/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs-extra'); 4 | const NestedError = require('nested-error-stacks'); 5 | const sharp = require('sharp'); 6 | const OriginalIMG = require('./original-image'); 7 | const BoundedIMG = require('./bounded-image'); 8 | 9 | const createimage = async (img, {boundingBox} = {}) => { 10 | return boundingBox 11 | ? BoundedIMG.create(img, boundingBox) 12 | : OriginalIMG.create(img); 13 | }; 14 | 15 | exports.fromBuffer = async (buffer, opts) => { 16 | const img = sharp(buffer, opts); 17 | return createimage(img, opts); 18 | }; 19 | 20 | exports.fromFile = async (filePath, opts = {}) => { 21 | try { 22 | const buffer = await fs.readFile(filePath); 23 | return exports.fromBuffer(buffer, opts); 24 | } catch (err) { 25 | throw new NestedError(`Can't load img file ${filePath}`, err); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /lib/image/original-image.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Image = require('./image'); 4 | 5 | module.exports = class OriginalImage extends Image { 6 | getActualCoord(x, y) { 7 | return {x, y}; 8 | } 9 | 10 | get width() { 11 | return this._width; 12 | } 13 | 14 | get height() { 15 | return this._height; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /lib/img-buffer/bounded-buffer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const IMGBuffer = require('./buffer'); 4 | 5 | module.exports = class BoundedIMGBuffer extends IMGBuffer { 6 | constructor(buffer, boundingBox) { 7 | super(buffer); 8 | 9 | this._boundingBox = boundingBox; 10 | } 11 | 12 | getActualCoord(x, y) { 13 | return {x: x + this._boundingBox.left, y: y + this._boundingBox.top}; 14 | } 15 | 16 | get width() { 17 | return this._boundingBox.right - this._boundingBox.left + 1; 18 | } 19 | 20 | get height() { 21 | return this._boundingBox.bottom - this._boundingBox.top + 1; 22 | } 23 | 24 | get boundingBox() { 25 | return this._boundingBox; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /lib/img-buffer/buffer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ImageBase = require('../image-base'); 4 | 5 | module.exports = class IMGBuffer extends ImageBase { 6 | constructor(buffer) { 7 | super(); 8 | 9 | this._buffer = buffer; 10 | } 11 | 12 | get buffer() { 13 | return this._buffer; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /lib/img-buffer/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs-extra'); 4 | const NestedError = require('nested-error-stacks'); 5 | const OriginalBuffer = require('./original-buffer'); 6 | const BoundedBuffer = require('./bounded-buffer'); 7 | 8 | exports.create = (buffer, {boundingBox} = {}) => { 9 | return boundingBox 10 | ? BoundedBuffer.create(buffer, boundingBox) 11 | : OriginalBuffer.create(buffer); 12 | }; 13 | 14 | exports.fromFile = async (filePath, opts = {}) => { 15 | try { 16 | const buffer = await fs.readFile(filePath); 17 | return exports.create(buffer, opts); 18 | } catch (err) { 19 | throw new NestedError(`Can't load img file ${filePath}`, err); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /lib/img-buffer/original-buffer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const IMGBuffer = require('./buffer'); 4 | 5 | const IMG_WIDTH_OFFSET = 16; 6 | const IMG_HEIGHT_OFFSET = 20; 7 | 8 | module.exports = class OriginalIMGBuffer extends IMGBuffer { 9 | getActualCoord(x, y) { 10 | return {x, y}; 11 | } 12 | 13 | get width() { 14 | return this._buffer.readUInt32BE(IMG_WIDTH_OFFSET); 15 | } 16 | 17 | get height() { 18 | return this._buffer.readUInt32BE(IMG_HEIGHT_OFFSET); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /lib/same-colors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (data) => { 4 | const c1 = data.color1; 5 | const c2 = data.color2; 6 | 7 | return c1.R === c2.R 8 | && c1.G === c2.G 9 | && c1.B === c2.B; 10 | }; 11 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const parseColor = require('parse-color'); 5 | const img = require('./image'); 6 | const buffer = require('./img-buffer'); 7 | const DiffArea = require('./diff-area'); 8 | const DiffClusters = require('./diff-clusters'); 9 | const validators = require('./validators'); 10 | const areColorsSame = require('./same-colors'); 11 | const {DIFF_IMAGE_CHANNELS} = require('./constants'); 12 | 13 | exports.readImgCb = async ({source, ...opts}) => { 14 | const readFunc = Buffer.isBuffer(source) ? img.fromBuffer : img.fromFile; 15 | const image = await readFunc(source, opts); 16 | 17 | await image.init(); 18 | 19 | return image; 20 | }; 21 | 22 | exports.readBufferCb = ({source, ...opts}) => { 23 | const readFunc = Buffer.isBuffer(source) ? buffer.create : buffer.fromFile; 24 | return readFunc(source, opts); 25 | }; 26 | 27 | exports.readPair = async (first, second, readCb = exports.readImgCb) => { 28 | const [firstImg, secondImg] = await Promise.all([first, second].map(readCb)); 29 | 30 | return {first: firstImg, second: secondImg}; 31 | }; 32 | 33 | const getDiffClusters = (diffClusters, diffArea, {shouldCluster}) => { 34 | return shouldCluster ? diffClusters.clusters : [diffArea.area]; 35 | }; 36 | 37 | exports.getDiffPixelsCoords = async (img1, img2, predicate, opts = {}) => { 38 | const stopOnFirstFail = opts.hasOwnProperty('stopOnFirstFail') ? opts.stopOnFirstFail : false; 39 | 40 | const width = Math.min(img1.width, img2.width); 41 | const height = Math.min(img1.height, img2.height); 42 | 43 | const diffArea = new DiffArea(); 44 | const diffClusters = new DiffClusters(opts.clustersSize); 45 | 46 | return new Promise((resolve) => { 47 | const processRow = (y) => { 48 | setImmediate(() => { 49 | for (let x = 0; x < width; x++) { 50 | const color1 = img1.getPixel(x, y); 51 | const color2 = img2.getPixel(x, y); 52 | 53 | const result = predicate({ 54 | color1, color2, 55 | img1, img2, 56 | x, y, 57 | width, height 58 | }); 59 | 60 | if (!result) { 61 | const {x: actX, y: actY} = img1.getActualCoord(x, y); 62 | diffArea.update(actX, actY); 63 | if (opts.shouldCluster) { 64 | diffClusters.update(actX, actY); 65 | } 66 | 67 | if (stopOnFirstFail) { 68 | return resolve({diffArea, diffClusters: getDiffClusters(diffClusters, diffArea, opts)}); 69 | } 70 | } 71 | } 72 | 73 | y++; 74 | 75 | if (y < height) { 76 | processRow(y); 77 | } else { 78 | resolve({diffArea, diffClusters: getDiffClusters(diffClusters, diffArea, opts)}); 79 | } 80 | }); 81 | }; 82 | 83 | processRow(0); 84 | }); 85 | }; 86 | 87 | exports.formatImages = (img1, img2) => { 88 | validators.validateImages(img1, img2); 89 | 90 | return [img1, img2].map((i) => { 91 | return _.isObject(i) && !Buffer.isBuffer(i) ? i : {source: i, boundingBox: null}; 92 | }); 93 | }; 94 | 95 | exports.areBuffersEqual = (img1, img2) => { 96 | if (img1.boundingBox || img2.boundingBox) { 97 | return false; 98 | } 99 | 100 | return img1.buffer.equals(img2.buffer); 101 | }; 102 | 103 | exports.parseColorString = (str) => { 104 | const parsed = parseColor(str || '#ff00ff'); 105 | 106 | return { 107 | R: parsed.rgb[0], 108 | G: parsed.rgb[1], 109 | B: parsed.rgb[2] 110 | }; 111 | }; 112 | 113 | exports.calcDiffImage = async (img1, img2, comparator, {highlightColor, shouldCluster, clustersSize}) => { 114 | const diffColor = exports.parseColorString(highlightColor); 115 | 116 | const minHeight = Math.min(img1.height, img2.height); 117 | const minWidth = Math.min(img1.width, img2.width); 118 | 119 | const maxHeight = Math.max(img1.height, img2.height); 120 | const maxWidth = Math.max(img1.width, img2.width); 121 | 122 | const totalPixels = maxHeight * maxWidth; 123 | const metaInfo = {refImg: {size: {width: img1.width, height: img1.height}}}; 124 | 125 | const diffBuffer = Buffer.alloc(maxHeight * maxWidth * DIFF_IMAGE_CHANNELS); 126 | const diffArea = new DiffArea(); 127 | const diffClusters = new DiffClusters(clustersSize); 128 | 129 | let differentPixels = 0; 130 | let diffBufferPos = 0; 131 | 132 | const markDiff = (x, y) => { 133 | diffBuffer[diffBufferPos++] = diffColor.R; 134 | diffBuffer[diffBufferPos++] = diffColor.G; 135 | diffBuffer[diffBufferPos++] = diffColor.B; 136 | differentPixels++; 137 | 138 | diffArea.update(x, y); 139 | if (shouldCluster) { 140 | diffClusters.update(x, y); 141 | } 142 | }; 143 | 144 | for (let y = 0; y < maxHeight; y++) { 145 | for (let x = 0; x < maxWidth; x++) { 146 | if (y >= minHeight || x >= minWidth) { 147 | markDiff(x, y); // Out of bounds pixels considered as diff 148 | continue; 149 | } 150 | 151 | const color1 = img1.getPixel(x, y); 152 | const color2 = img2.getPixel(x, y); 153 | 154 | const areSame = areColorsSame({color1, color2}) || comparator({ 155 | img1, 156 | img2, 157 | x, 158 | y, 159 | color1, 160 | color2, 161 | width: maxWidth, 162 | height: maxHeight, 163 | minWidth, 164 | minHeight 165 | }); 166 | 167 | if (areSame) { 168 | diffBuffer[diffBufferPos++] = color2.R; 169 | diffBuffer[diffBufferPos++] = color2.G; 170 | diffBuffer[diffBufferPos++] = color2.B; 171 | } else { 172 | markDiff(x, y); 173 | } 174 | } 175 | 176 | // eslint-disable-next-line no-bitwise 177 | if (!(y & 0xff)) { // Release event queue every 256 rows 178 | await new Promise(setImmediate); 179 | } 180 | } 181 | 182 | let diffImage = null; 183 | 184 | if (differentPixels) { 185 | diffImage = await img.fromBuffer(diffBuffer, {raw: {width: maxWidth, height: maxHeight, channels: DIFF_IMAGE_CHANNELS}}); 186 | await diffImage.initMeta(); 187 | } 188 | 189 | return { 190 | equal: !differentPixels, 191 | metaInfo, 192 | diffImage, 193 | differentPixels, 194 | totalPixels, 195 | diffBounds: diffArea.area, 196 | diffClusters: getDiffClusters(diffClusters, diffArea, {shouldCluster}) 197 | }; 198 | }; 199 | -------------------------------------------------------------------------------- /lib/validators.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const {REQUIRED_IMAGE_FIELDS, REQUIRED_BOUNDING_BOX_FIELDS} = require('./constants'); 5 | 6 | const validateRequiredFields = (value, fields) => { 7 | [].concat(fields).forEach((field) => { 8 | if (!_.hasIn(value, field)) { 9 | throw new TypeError(`Field "${field}" does not exist in ${JSON.stringify(value)}`); 10 | } 11 | }); 12 | }; 13 | 14 | const validateBoundingBoxCoords = ({boundingBox}) => { 15 | if (boundingBox.left > boundingBox.right) { 16 | throw new TypeError('"left" coordinate in "boundingBox" field cannot be greater than "right"'); 17 | } 18 | 19 | if (boundingBox.top > boundingBox.bottom) { 20 | throw new TypeError('"top" coordinate in "boundingBox" field cannot be greater than "bottom"'); 21 | } 22 | }; 23 | 24 | exports.validateImages = (img1, img2) => { 25 | [img1, img2].forEach((i) => { 26 | if (Buffer.isBuffer(i) || !_.isObject(i)) { 27 | return; 28 | } 29 | 30 | validateRequiredFields(i, REQUIRED_IMAGE_FIELDS); 31 | validateRequiredFields(i.boundingBox, REQUIRED_BOUNDING_BOX_FIELDS); 32 | validateBoundingBoxCoords(i); 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "looks-same", 3 | "version": "9.0.1", 4 | "description": "Pure node.js library for comparing PNG-images, taking into account human color perception.", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "directories": { 8 | "test": "test" 9 | }, 10 | "dependencies": { 11 | "color-diff": "^1.1.0", 12 | "fs-extra": "^8.1.0", 13 | "js-graph-algorithms": "1.0.18", 14 | "lodash": "^4.17.3", 15 | "nested-error-stacks": "^2.1.0", 16 | "parse-color": "^1.0.0", 17 | "sharp": "0.32.6" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^10.12.3", 21 | "chai": "^4.1.2", 22 | "chai-as-promised": "^7.1.1", 23 | "eslint": "^5.3.0", 24 | "eslint-config-gemini-testing": "^3.0.0", 25 | "gm": "^1.23.1", 26 | "mocha": "^5.2.0", 27 | "proxyquire": "^1.7.10", 28 | "sinon": "^6.1.5", 29 | "sinon-chai": "^3.3.0", 30 | "standard-version": "^7.0.0", 31 | "temp": "^0.8.3" 32 | }, 33 | "scripts": { 34 | "test-unit": "mocha", 35 | "lint": "eslint .", 36 | "test": "npm run test-unit && npm run lint", 37 | "release": "standard-version" 38 | }, 39 | "engines": { 40 | "node": ">= 18.0.0" 41 | }, 42 | "author": "Sergey Tatarintsev (https://github.com/SevInf)", 43 | "license": "MIT", 44 | "repository": { 45 | "type": "git", 46 | "url": "https://github.com/gemini-testing/looks-same.git" 47 | }, 48 | "keywords": [ 49 | "png", 50 | "compare", 51 | "ciede2000", 52 | "diff" 53 | ], 54 | "bugs": { 55 | "url": "https://github.com/gemini-testing/looks-same/issues" 56 | }, 57 | "homepage": "https://github.com/gemini-testing/looks-same" 58 | } 59 | -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'gemini-testing/tests' 3 | }; 4 | -------------------------------------------------------------------------------- /test/assert-ext.js: -------------------------------------------------------------------------------- 1 | global.assert.calledOnceWith = function() { 2 | assert.calledOnce(arguments[0]); 3 | assert.calledWith.apply(null, arguments); 4 | }; 5 | -------------------------------------------------------------------------------- /test/data/diffs/small-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/diffs/small-green.png -------------------------------------------------------------------------------- /test/data/diffs/small-magenta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/diffs/small-magenta.png -------------------------------------------------------------------------------- /test/data/diffs/strict.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/diffs/strict.png -------------------------------------------------------------------------------- /test/data/diffs/taller-magenta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/diffs/taller-magenta.png -------------------------------------------------------------------------------- /test/data/diffs/wider-magenta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/diffs/wider-magenta.png -------------------------------------------------------------------------------- /test/data/src/1px-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/1px-diff.png -------------------------------------------------------------------------------- /test/data/src/antialiasing-actual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/antialiasing-actual.png -------------------------------------------------------------------------------- /test/data/src/antialiasing-ref.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/antialiasing-ref.png -------------------------------------------------------------------------------- /test/data/src/antialiasing-tolerance-actual-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/antialiasing-tolerance-actual-1.png -------------------------------------------------------------------------------- /test/data/src/antialiasing-tolerance-actual-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/antialiasing-tolerance-actual-2.png -------------------------------------------------------------------------------- /test/data/src/antialiasing-tolerance-ref-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/antialiasing-tolerance-ref-1.png -------------------------------------------------------------------------------- /test/data/src/antialiasing-tolerance-ref-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/antialiasing-tolerance-ref-2.png -------------------------------------------------------------------------------- /test/data/src/blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/blue.png -------------------------------------------------------------------------------- /test/data/src/bounding-box-diff-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/bounding-box-diff-1.png -------------------------------------------------------------------------------- /test/data/src/bounding-box-diff-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/bounding-box-diff-2.png -------------------------------------------------------------------------------- /test/data/src/bounding-box-ref-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/bounding-box-ref-1.png -------------------------------------------------------------------------------- /test/data/src/bounding-box-ref-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/bounding-box-ref-2.png -------------------------------------------------------------------------------- /test/data/src/broken-caret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/broken-caret.png -------------------------------------------------------------------------------- /test/data/src/caret+antialiasing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/caret+antialiasing.png -------------------------------------------------------------------------------- /test/data/src/caret+text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/caret+text.png -------------------------------------------------------------------------------- /test/data/src/caret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/caret.png -------------------------------------------------------------------------------- /test/data/src/different-unnoticable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/different-unnoticable.png -------------------------------------------------------------------------------- /test/data/src/different.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/different.png -------------------------------------------------------------------------------- /test/data/src/green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/green.png -------------------------------------------------------------------------------- /test/data/src/large-different.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/large-different.png -------------------------------------------------------------------------------- /test/data/src/large-ref.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/large-ref.png -------------------------------------------------------------------------------- /test/data/src/no-caret+antialiasing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/no-caret+antialiasing.png -------------------------------------------------------------------------------- /test/data/src/no-caret+text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/no-caret+text.png -------------------------------------------------------------------------------- /test/data/src/no-caret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/no-caret.png -------------------------------------------------------------------------------- /test/data/src/not-only-caret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/not-only-caret.png -------------------------------------------------------------------------------- /test/data/src/red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/red.png -------------------------------------------------------------------------------- /test/data/src/ref.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/ref.png -------------------------------------------------------------------------------- /test/data/src/same.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/same.png -------------------------------------------------------------------------------- /test/data/src/tall-different.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/tall-different.png -------------------------------------------------------------------------------- /test/data/src/tall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/tall.png -------------------------------------------------------------------------------- /test/data/src/two-caret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/two-caret.png -------------------------------------------------------------------------------- /test/data/src/wide-different.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/wide-different.png -------------------------------------------------------------------------------- /test/data/src/wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gemini-testing/looks-same/ac14c63645caa47cbe909ba38a6a57aa6f35175c/test/data/src/wide.png -------------------------------------------------------------------------------- /test/diff-area.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const expect = require('chai').expect; 4 | const DiffArea = require('../lib/diff-area'); 5 | 6 | describe('DiffArea', () => { 7 | it('should init diff area with default params', () => { 8 | const diffArea = new DiffArea(); 9 | 10 | expect(diffArea.area).to.deep.equal({left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity}); 11 | }); 12 | 13 | it('should update diff area', () => { 14 | const diffArea = new DiffArea(); 15 | 16 | diffArea.update(99, 99); 17 | 18 | expect(diffArea.area).to.deep.equal({left: 99, top: 99, right: 99, bottom: 99}); 19 | }); 20 | 21 | describe('isEmpty', () => { 22 | it('should return "true" if area is empty', () => { 23 | const diffArea = new DiffArea(); 24 | 25 | expect(diffArea.isEmpty()).to.equal(true); 26 | }); 27 | 28 | it('should return "false" if area is not empty', () => { 29 | const diffArea = new DiffArea(); 30 | 31 | diffArea.update(99, 99); 32 | 33 | expect(diffArea.isEmpty()).to.equal(false); 34 | }); 35 | }); 36 | 37 | describe('isPointInArea', () => { 38 | it('should return "true" if point inside of area', () => { 39 | const diffArea = new DiffArea(); 40 | 41 | diffArea 42 | .update(1, 1) 43 | .update(5, 5); 44 | 45 | assert.isTrue(diffArea.isPointInArea(10, 10, 10)); 46 | }); 47 | 48 | it('should return "false" if point is outside of area', () => { 49 | const diffArea = new DiffArea(); 50 | 51 | diffArea 52 | .update(1, 1) 53 | .update(5, 5); 54 | 55 | expect(diffArea.isPointInArea(20, 20, 10)).to.equal(false); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/diff-clusters/clusters-joiner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const clustersJoiner = require('../../lib/diff-clusters/clusters-joiner'); 4 | 5 | describe('DiffClusters', () => { 6 | it('should join clusters', () => { 7 | const clusters = [ 8 | {area: {left: 1, top: 1, right: 5, bottom: 5}}, 9 | {area: {left: 2, top: 2, right: 6, bottom: 6}}, 10 | {area: {left: 10, top: 10, right: 11, bottom: 11}} 11 | ]; 12 | const joinedClusters = clustersJoiner.join(clusters).map(c => c.area); 13 | 14 | assert.deepEqual(joinedClusters, [ 15 | {left: 1, top: 1, right: 6, bottom: 6}, 16 | {left: 10, top: 10, right: 11, bottom: 11} 17 | ]); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/diff-clusters/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const DiffArea = require('../../lib/diff-area'); 4 | const DiffClusters = require('../../lib/diff-clusters'); 5 | const clustersJoiner = require('../../lib/diff-clusters/clusters-joiner'); 6 | 7 | describe('DiffClusters', () => { 8 | const sandbox = sinon.createSandbox(); 9 | 10 | beforeEach(() => { 11 | sandbox.stub(clustersJoiner, 'join'); 12 | sandbox.stub(DiffArea.prototype, 'isPointInArea'); 13 | }); 14 | 15 | afterEach(() => sandbox.restore()); 16 | 17 | it('should define points to different clusters', () => { 18 | DiffArea.prototype.isPointInArea.returns(false); 19 | clustersJoiner.join = (clusters) => clusters; 20 | const diffClusters = new DiffClusters(); 21 | 22 | diffClusters.update(1, 1); 23 | diffClusters.update(5, 5); 24 | 25 | assert.deepEqual(diffClusters.clusters, [ 26 | {left: 1, top: 1, right: 1, bottom: 1}, 27 | {left: 5, top: 5, right: 5, bottom: 5} 28 | ]); 29 | }); 30 | 31 | it('should define points to the same clusters', () => { 32 | DiffArea.prototype.isPointInArea.returns(true); 33 | clustersJoiner.join = (clusters) => clusters; 34 | const diffClusters = new DiffClusters(); 35 | 36 | diffClusters.update(1, 1); 37 | diffClusters.update(5, 5); 38 | 39 | assert.deepEqual(diffClusters.clusters, [{left: 1, top: 1, right: 5, bottom: 5}]); 40 | }); 41 | 42 | it('should return joined clusters', () => { 43 | DiffArea.prototype.isPointInArea.returns(false); 44 | clustersJoiner.join.returns([{area: {left: 1, top: 1, right: 5, bottom: 5}}]); 45 | const diffClusters = new DiffClusters(); 46 | 47 | diffClusters.update(1, 1); 48 | diffClusters.update(5, 5); 49 | 50 | assert.deepEqual(diffClusters.clusters, [{left: 1, top: 1, right: 5, bottom: 5}]); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/ignore-caret-comparator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const expect = require('chai').expect; 5 | const sinon = require('sinon'); 6 | const proxyquire = require('proxyquire'); 7 | 8 | describe('IgnoreCaretComparator', () => { 9 | const sandbox = sinon.createSandbox(); 10 | 11 | let IgnoreCaretComparator; 12 | let areColorsSame; 13 | 14 | const compareImages = (pixels, comparator) => { 15 | const emptyPixels = _.map(pixels, (pixelRow) => Array(pixelRow.length).fill(0)); 16 | const width = pixels[0].length; 17 | const height = pixels.length; 18 | 19 | const img1 = {data: pixels, getPixel: (x, y) => pixels[y][x], width, height}; 20 | const img2 = {data: emptyPixels, getPixel: (x, y) => emptyPixels[y][x], width, height}; 21 | 22 | let res = true; 23 | 24 | for (let y = 0; y < pixels.length; ++y) { 25 | for (let x = 0; x < pixels[y].length; ++x) { 26 | res = comparator({ 27 | color1: img1.data[y][x], 28 | color2: img2.data[y][x], 29 | x, 30 | y, 31 | img1, 32 | img2, 33 | minWidth: width, 34 | minHeight: height 35 | }); 36 | if (!res) { 37 | break; 38 | } 39 | } 40 | if (!res) { 41 | break; 42 | } 43 | } 44 | 45 | return res; 46 | }; 47 | 48 | const execComparator = (params, pixels) => { 49 | const colorComparator = (data) => data.color1 === data.color2; 50 | const ignoreCaretComparator = new IgnoreCaretComparator(colorComparator, params.pixelRatio); 51 | return compareImages(pixels, ignoreCaretComparator.compare.bind(ignoreCaretComparator)); 52 | }; 53 | 54 | const expectAccepted = (params, pixels) => { 55 | expect(execComparator(params, pixels)).to.equal(true); 56 | }; 57 | 58 | const expectDeclined = (params, pixels) => { 59 | expect(execComparator(params, pixels)).to.equal(false); 60 | }; 61 | 62 | beforeEach(() => { 63 | areColorsSame = sandbox.stub().returns(true); 64 | areColorsSame 65 | .withArgs({color1: 1, color2: 0}).returns(false) 66 | .withArgs({color1: 0, color2: 1}).returns(false) 67 | .returns(true); 68 | areColorsSame['@global'] = true; 69 | 70 | IgnoreCaretComparator = proxyquire('../lib/ignore-caret-comparator', { 71 | '../../same-colors': areColorsSame 72 | }); 73 | }); 74 | 75 | afterEach(() => sandbox.restore()); 76 | 77 | it('should accept equal images', () => { 78 | expectAccepted({pixelRatio: 1}, [ 79 | [0, 0, 0, 0], 80 | [0, 0, 0, 0], 81 | [0, 0, 0, 0], 82 | [0, 0, 0, 0] 83 | ]); 84 | }); 85 | 86 | it('should decline images with 1px diff', () => { 87 | expectDeclined({pixelRatio: 1}, [ 88 | [0, 0, 0, 0], 89 | [0, 1, 0, 0], 90 | [0, 0, 0, 0], 91 | [0, 0, 0, 0] 92 | ]); 93 | }); 94 | 95 | it('should accept images with caret which width is equal to given pixelRatio', () => { 96 | expectAccepted({pixelRatio: 1}, [ 97 | [0, 0, 0, 0], 98 | [0, 1, 0, 0], 99 | [0, 1, 0, 0], 100 | [0, 0, 0, 0] 101 | ]); 102 | }); 103 | 104 | it('should decline images with caret which width is greater than given pixelRatio', () => { 105 | expectDeclined({pixelRatio: 1}, [ 106 | [0, 0, 0, 0], 107 | [0, 1, 1, 0], 108 | [0, 1, 1, 0], 109 | [0, 0, 0, 0] 110 | ]); 111 | }); 112 | 113 | it('should decline images with caret which width is greater than given fractional pixelRatio', () => { 114 | expectDeclined({pixelRatio: 1.5}, [ 115 | [0, 0, 0, 0], 116 | [0, 1, 1, 0], 117 | [0, 1, 1, 0], 118 | [0, 0, 0, 0] 119 | ]); 120 | }); 121 | 122 | it('should decline images with caret which width is less than given pixelRatio', () => { 123 | expectDeclined({pixelRatio: 2}, [ 124 | [0, 0, 0, 0], 125 | [0, 1, 0, 0], 126 | [0, 1, 0, 0], 127 | [0, 0, 0, 0] 128 | ]); 129 | }); 130 | 131 | it('should decline images with 1px height diff', () => { 132 | expectDeclined({pixelRatio: 2}, [ 133 | [0, 0, 0, 0], 134 | [0, 1, 1, 0], 135 | [0, 0, 0, 0], 136 | [0, 0, 0, 0] 137 | ]); 138 | }); 139 | 140 | it('should decline images which variable caret width (pixelRatio is 2)', () => { 141 | expectDeclined({pixelRatio: 2}, [ 142 | [0, 0, 0, 0], 143 | [0, 1, 1, 0], 144 | [0, 1, 0, 0], 145 | [0, 0, 0, 0] 146 | ]); 147 | }); 148 | 149 | it('should decline images which variable caret width (pixelRatio is 1)', () => { 150 | expectDeclined({pixelRatio: 1}, [ 151 | [0, 0, 0, 0], 152 | [0, 1, 0, 0], 153 | [0, 1, 1, 0], 154 | [0, 0, 0, 0] 155 | ]); 156 | }); 157 | 158 | it('should decline images with multiple carets (more than 1 vertical lines)', () => { 159 | expectDeclined({pixelRatio: 1}, [ 160 | [0, 0, 0, 0], 161 | [0, 1, 0, 1], 162 | [0, 1, 0, 1], 163 | [0, 0, 0, 0] 164 | ]); 165 | }); 166 | 167 | it('should decline images with more difference, than caret', () => { 168 | expectDeclined({pixelRatio: 1}, [ 169 | [0, 0, 0, 0], 170 | [0, 1, 0, 1], 171 | [0, 1, 0, 0], 172 | [0, 0, 0, 0] 173 | ]); 174 | }); 175 | 176 | it('should decline images with broken carets (hole inside caret)', () => { 177 | expectDeclined({pixelRatio: 1}, [ 178 | [0, 1, 0, 0], 179 | [0, 0, 0, 0], 180 | [0, 1, 0, 0], 181 | [0, 1, 0, 0] 182 | ]); 183 | }); 184 | 185 | it('should accept images with caret in the bottom right corner', () => { 186 | expectAccepted({pixelRatio: 1}, [ 187 | [0, 0, 0, 0], 188 | [0, 0, 0, 0], 189 | [0, 0, 0, 1], 190 | [0, 0, 0, 1] 191 | ]); 192 | }); 193 | 194 | it('should decline images with difference on the right border (pixelRatio is 2)', () => { 195 | expectDeclined({pixelRatio: 2}, [ 196 | [0, 0, 0, 0], 197 | [0, 0, 0, 1], 198 | [0, 0, 0, 1], 199 | [0, 0, 0, 0] 200 | ]); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ./test/setup 2 | --require ./test/assert-ext 3 | --recursive 4 | -------------------------------------------------------------------------------- /test/png/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | const proxyquire = require('proxyquire'); 5 | const fs = require('fs-extra'); 6 | 7 | const stubBuffer = Buffer.from([123]); 8 | 9 | describe('lib/image/index.js', () => { 10 | const sandbox = sinon.createSandbox(); 11 | let parseError; 12 | let image; 13 | let mkSharpImage_; 14 | beforeEach(() => { 15 | parseError = null; 16 | mkSharpImage_ = sandbox.stub(); 17 | image = proxyquire('../../lib/image', {'sharp': mkSharpImage_}); 18 | 19 | sandbox.stub(fs, 'readFile').resolves(stubBuffer); 20 | }); 21 | 22 | afterEach(() => sandbox.restore()); 23 | 24 | describe('fromFile', () => { 25 | it('should parse and return sharp instance', async () => { 26 | await image.fromFile('/filePath'); 27 | 28 | assert.calledOnceWith(mkSharpImage_, stubBuffer); 29 | }); 30 | 31 | it('should throw error with file path and original error message at stack', async () => { 32 | parseError = new Error('test error'); 33 | fs.readFile.withArgs('/filePath').rejects(parseError); 34 | 35 | const error = await assert.isRejected(image.fromFile('/filePath')); 36 | 37 | assert.match(error.message, 'Can\'t load img file /filePath'); 38 | assert.match(error.stack, 'Error: test error'); 39 | }); 40 | }); 41 | 42 | it('should create image from raw buffer', async () => { 43 | const rawBuffer = 'foo'; 44 | const rawOpts = { 45 | raw: { 46 | width: 100500, 47 | height: 500100, 48 | channels: 42 49 | } 50 | }; 51 | 52 | await image.fromBuffer(rawBuffer, rawOpts); 53 | 54 | assert.calledOnceWith(mkSharpImage_, rawBuffer, rawOpts); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chai = require('chai'); 4 | 5 | global.sinon = require('sinon'); 6 | global.assert = chai.assert; 7 | 8 | chai.use(require('sinon-chai')); 9 | chai.use(require('chai-as-promised')); 10 | sinon.assert.expose(chai.assert, {prefix: ''}); 11 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const temp = require('temp'); 6 | const expect = require('chai').expect; 7 | 8 | const looksSame = require('..'); 9 | const utils = require('../lib/utils'); 10 | 11 | const imagePath = (name) => path.join(__dirname, 'data', name); 12 | 13 | const srcPath = (name) => path.join(imagePath(path.join('src', name))); 14 | 15 | const readImage = (name) => fs.readFileSync(srcPath(name)); 16 | 17 | const forFilesAndBuffers = (callback) => { 18 | describe('with files as arguments', () => { 19 | callback(srcPath); 20 | }); 21 | 22 | describe('with buffers as arguments', () => { 23 | callback(readImage); 24 | }); 25 | }; 26 | 27 | describe('looksSame', () => { 28 | const sandbox = sinon.createSandbox(); 29 | 30 | afterEach(() => { 31 | sandbox.restore(); 32 | }); 33 | 34 | it('should throw if both tolerance and strict options set', async () => { 35 | await expect(looksSame(srcPath('ref.png'), srcPath('same.png'), { 36 | strict: true, 37 | tolerance: 9000 38 | })).to.eventually.be.rejectedWith(TypeError); 39 | }); 40 | 41 | it('should work when opts is undefined', async () => { 42 | await expect(looksSame(srcPath('ref.png'), srcPath('same.png'))) 43 | .to.eventually.be.fulfilled; 44 | }); 45 | 46 | it('should format images', async () => { 47 | sandbox.spy(utils, 'formatImages'); 48 | 49 | await looksSame(srcPath('ref.png'), srcPath('same.png')); 50 | 51 | assert.calledOnceWith(utils.formatImages, srcPath('ref.png'), srcPath('same.png')); 52 | }); 53 | 54 | it('should read formatted images', async () => { 55 | const [formattedImg1, formattedImg2] = [{source: srcPath('ref.png')}, {source: srcPath('same.png')}]; 56 | sandbox.stub(utils, 'formatImages').returns([formattedImg1, formattedImg2]); 57 | sandbox.spy(utils, 'readPair'); 58 | 59 | await looksSame(srcPath('ref.png'), srcPath('same.png')); 60 | 61 | assert.calledOnceWith(utils.readPair, formattedImg1, formattedImg2); 62 | }); 63 | 64 | forFilesAndBuffers((getImage) => { 65 | it('should return true for similar images (compare only by buffers)', async () => { 66 | sandbox.stub(utils, 'getDiffPixelsCoords'); 67 | 68 | const {equal} = await looksSame(getImage('ref.png'), getImage('same.png')); 69 | 70 | assert.isTrue(equal); 71 | assert.notCalled(utils.getDiffPixelsCoords); 72 | }); 73 | 74 | it('should return false for different images (compare by img pixels)', async () => { 75 | sandbox.spy(utils, 'getDiffPixelsCoords'); 76 | 77 | const {equal} = await looksSame(getImage('ref.png'), getImage('different.png')); 78 | 79 | assert.isFalse(equal); 80 | assert.calledOnce(utils.getDiffPixelsCoords); 81 | }); 82 | 83 | it('should return reference image for different images', async () => { 84 | const {metaInfo: {refImg}} = await looksSame(getImage('ref.png'), getImage('different.png')); 85 | 86 | expect(refImg).to.deep.equal({size: {width: 50, height: 50}}); 87 | }); 88 | 89 | it('should return reference image for equal images', async () => { 90 | const {metaInfo: {refImg}} = await looksSame(getImage('ref.png'), getImage('ref.png')); 91 | 92 | expect(refImg).to.deep.equal({size: {width: 50, height: 50}}); 93 | }); 94 | 95 | it('should return diff bounds for different images', async () => { 96 | const {diffBounds} = await looksSame(getImage('ref.png'), getImage('different.png')); 97 | 98 | expect(diffBounds).to.deep.equal({left: 0, top: 10, right: 49, bottom: 39}); 99 | }); 100 | 101 | it('should return true for different images when tolerance is higher than difference', async () => { 102 | const {equal} = await looksSame(getImage('ref.png'), getImage('different.png'), {tolerance: 50}); 103 | 104 | expect(equal).to.equal(true); 105 | }); 106 | 107 | it('should return true for different images when difference is not seen by human eye', async () => { 108 | const {equal} = await looksSame(getImage('ref.png'), getImage('different-unnoticable.png')); 109 | 110 | expect(equal).to.equal(true); 111 | }); 112 | 113 | it('should return false if difference is not seen by human eye and strict mode is enabled', async () => { 114 | const {equal} = await looksSame(getImage('ref.png'), getImage('different-unnoticable.png'), {strict: true}); 115 | 116 | expect(equal).to.equal(false); 117 | }); 118 | 119 | it('should work when images width does not match', async () => { 120 | const {equal} = await looksSame(getImage('ref.png'), getImage('wide.png')); 121 | 122 | expect(equal).to.equal(false); 123 | }); 124 | 125 | it('should work when images height does not match', async () => { 126 | const {equal} = await looksSame(getImage('ref.png'), getImage('tall.png')); 127 | 128 | expect(equal).to.equal(false); 129 | }); 130 | 131 | it('should return diff bound equal to a bigger image if images have different sizes', async () => { 132 | const {equal, diffBounds} = await looksSame(srcPath('ref.png'), srcPath('large-different.png')); 133 | 134 | expect(equal).to.equal(false); 135 | expect(diffBounds).to.deep.equal({left: 0, top: 0, right: 499, bottom: 499}); 136 | }); 137 | 138 | it('should return single diff cluster equal to a bigger image if images have different sizes', async () => { 139 | const {equal, diffClusters} = await looksSame(srcPath('ref.png'), srcPath('large-different.png')); 140 | 141 | expect(equal).to.equal(false); 142 | expect(diffClusters).to.deep.equal([{left: 0, top: 0, right: 499, bottom: 499}]); 143 | }); 144 | 145 | [ 146 | 'red', 147 | 'blue', 148 | 'green' 149 | ].forEach((channel) => { 150 | it(`should report image as different if the difference is only in ${channel} channel`, async () => { 151 | const {equal} = await looksSame(getImage('ref.png'), getImage(`${channel}.png`)); 152 | 153 | expect(equal).to.equal(false); 154 | }); 155 | }); 156 | 157 | it('should return false for images which differ from each other only by 1 pixel', async () => { 158 | const {equal} = await looksSame(getImage('no-caret.png'), getImage('1px-diff.png')); 159 | 160 | expect(equal).to.equal(false); 161 | }); 162 | }); 163 | 164 | describe('with comparing by areas', () => { 165 | forFilesAndBuffers((getImage) => { 166 | describe('if passed areas have different sizes', () => { 167 | it('should return "false"', async () => { 168 | const {equal} = await looksSame( 169 | {source: getImage('bounding-box-diff-1.png'), boundingBox: {left: 1, top: 1, right: 2, bottom: 1}}, 170 | {source: getImage('bounding-box-diff-1.png'), boundingBox: {left: 5, top: 5, right: 5, bottom: 6}} 171 | ); 172 | 173 | assert.isFalse(equal); 174 | }); 175 | 176 | it('should return diff bound for first image equal to a bigger area', async () => { 177 | const {diffBounds} = await looksSame( 178 | {source: getImage('bounding-box-diff-1.png'), boundingBox: {left: 1, top: 1, right: 2, bottom: 1}}, 179 | {source: getImage('bounding-box-diff-1.png'), boundingBox: {left: 5, top: 5, right: 5, bottom: 6}} 180 | ); 181 | 182 | assert.deepEqual(diffBounds, {left: 1, top: 1, right: 2, bottom: 2}); 183 | }); 184 | }); 185 | 186 | describe('if passed areas have the same sizes but located in various places', () => { 187 | it('should return true if images are equal', async () => { 188 | const {equal} = await looksSame( 189 | {source: getImage('bounding-box-diff-1.png'), boundingBox: {left: 1, top: 1, right: 4, bottom: 4}}, 190 | {source: getImage('bounding-box-diff-2.png'), boundingBox: {left: 5, top: 5, right: 8, bottom: 8}} 191 | ); 192 | 193 | assert.isTrue(equal); 194 | }); 195 | 196 | it('should return false if images are different', async () => { 197 | const {equal} = await looksSame( 198 | {source: getImage('bounding-box-ref-1.png'), boundingBox: {left: 1, top: 1, right: 4, bottom: 4}}, 199 | {source: getImage('bounding-box-ref-2.png'), boundingBox: {left: 5, top: 5, right: 8, bottom: 8}} 200 | ); 201 | 202 | assert.isFalse(equal); 203 | }); 204 | 205 | it('should return diff bound for first image if images are different', async () => { 206 | const {diffBounds} = await looksSame( 207 | {source: getImage('bounding-box-diff-1.png'), boundingBox: {left: 1, top: 1, right: 2, bottom: 1}}, 208 | {source: getImage('bounding-box-diff-1.png'), boundingBox: {left: 5, top: 5, right: 5, bottom: 6}} 209 | ); 210 | 211 | assert.deepEqual(diffBounds, {left: 1, top: 1, right: 2, bottom: 2}); 212 | }); 213 | }); 214 | }); 215 | }); 216 | 217 | describe('with ignoreCaret', () => { 218 | forFilesAndBuffers((getImage) => { 219 | it('should ignore caret by default', async () => { 220 | const {equal} = await looksSame(getImage('no-caret.png'), getImage('caret.png')); 221 | 222 | expect(equal).to.equal(true); 223 | }); 224 | 225 | it('if disabled, should return false for images with caret', async () => { 226 | const {equal} = await looksSame(getImage('no-caret.png'), getImage('caret.png'), {ignoreCaret: false}); 227 | 228 | expect(equal).to.equal(false); 229 | }); 230 | 231 | it('if enabled, should return true for images with caret', async () => { 232 | const {equal} = await looksSame(getImage('no-caret.png'), getImage('caret.png'), {ignoreCaret: true}); 233 | 234 | expect(equal).to.equal(true); 235 | }); 236 | 237 | it('if enabled, should return true for images with caret intersecting with a letter', async () => { 238 | const {equal} = await looksSame(getImage('no-caret+text.png'), getImage('caret+text.png'), {ignoreCaret: true}); 239 | 240 | expect(equal).to.equal(true); 241 | }); 242 | 243 | it('if enabled, should return true for images with caret and antialiased pixels', async () => { 244 | const {equal} = await looksSame(getImage('caret+antialiasing.png'), getImage('no-caret+antialiasing.png'), { 245 | ignoreCaret: true, 246 | ignoreAntialiasing: true 247 | }); 248 | 249 | expect(equal).to.equal(true); 250 | }); 251 | 252 | it('if enabled, should return false for images with 1px diff', async () => { 253 | const {equal} = await looksSame(getImage('no-caret.png'), getImage('1px-diff.png')); 254 | 255 | expect(equal).to.equal(false); 256 | }); 257 | }); 258 | }); 259 | 260 | describe('with antialiasing', () => { 261 | forFilesAndBuffers((getImage) => { 262 | it('should check images for antialiasing by default', async () => { 263 | const {equal} = await looksSame(getImage('antialiasing-ref.png'), getImage('antialiasing-actual.png')); 264 | 265 | expect(equal).to.equal(true); 266 | }); 267 | 268 | it('if disabled, should return false for images with antialiasing', async () => { 269 | const {equal} = await looksSame( 270 | getImage('antialiasing-ref.png'), 271 | getImage('antialiasing-actual.png'), 272 | {ignoreAntialiasing: false} 273 | ); 274 | 275 | expect(equal).to.equal(false); 276 | }); 277 | 278 | it('if enabled, should return true for images with antialiasing', async () => { 279 | const {equal} = await looksSame( 280 | getImage('antialiasing-ref.png'), 281 | getImage('antialiasing-actual.png'), 282 | {ignoreAntialiasing: true} 283 | ); 284 | 285 | expect(equal).to.equal(true); 286 | }); 287 | 288 | it('should return false for images which differ even with ignore antialiasing option', async () => { 289 | const {equal} = await looksSame( 290 | getImage('no-caret.png'), 291 | getImage('1px-diff.png'), 292 | {ignoreAntialiasing: true} 293 | ); 294 | 295 | expect(equal).to.equal(false); 296 | }); 297 | 298 | [1, 2].forEach((ind) => { 299 | it('should return false for images with default "antialiasingTolerance"', async () => { 300 | const {equal} = await looksSame( 301 | getImage(`antialiasing-tolerance-ref-${ind}.png`), 302 | getImage(`antialiasing-tolerance-actual-${ind}.png`), 303 | {ignoreAntialiasing: true, ignoreCaret: false} 304 | ); 305 | 306 | expect(equal).to.equal(false); 307 | }); 308 | 309 | it('should return true for images with passed "antialiasingTolerance"', async () => { 310 | const {equal} = await looksSame( 311 | getImage(`antialiasing-tolerance-ref-${ind}.png`), 312 | getImage(`antialiasing-tolerance-actual-${ind}.png`), 313 | {antialiasingTolerance: 4} 314 | ); 315 | 316 | expect(equal).to.equal(true); 317 | }); 318 | }); 319 | }); 320 | }); 321 | }); 322 | 323 | describe('createDiff', () => { 324 | const sandbox = sinon.createSandbox(); 325 | 326 | beforeEach(() => { 327 | this.tempName = temp.path({suffix: '.png'}); 328 | }); 329 | 330 | afterEach(() => { 331 | if (fs.existsSync(this.tempName)) { 332 | fs.unlinkSync(this.tempName); 333 | } 334 | 335 | sandbox.restore(); 336 | }); 337 | 338 | it('should throw if both tolerance and strict options set', async () => { 339 | await expect(looksSame.createDiff({ 340 | reference: srcPath('ref.png'), 341 | current: srcPath('different.png'), 342 | diff: this.tempName, 343 | highlightColor: '#ff00ff', 344 | tolerance: 9000, 345 | strict: true 346 | })).to.eventually.be.rejectedWith(TypeError); 347 | }); 348 | 349 | it('should format images', async () => { 350 | sandbox.spy(utils, 'formatImages'); 351 | 352 | await looksSame.createDiff({ 353 | reference: srcPath('ref.png'), 354 | current: srcPath('same.png'), 355 | diff: this.tempName, 356 | highlightColor: '#ff00ff' 357 | }); 358 | 359 | assert.calledOnceWith(utils.formatImages, srcPath('ref.png'), srcPath('same.png')); 360 | }); 361 | 362 | it('should read formatted images', async () => { 363 | const [formattedImg1, formattedImg2] = [{source: srcPath('ref.png')}, {source: srcPath('same.png')}]; 364 | sandbox.stub(utils, 'formatImages').returns([formattedImg1, formattedImg2]); 365 | sandbox.spy(utils, 'readPair'); 366 | 367 | await looksSame.createDiff({ 368 | reference: srcPath('ref.png'), 369 | current: srcPath('same.png'), 370 | diff: this.tempName, 371 | highlightColor: '#ff00ff' 372 | }); 373 | 374 | assert.calledOnceWith(utils.readPair, formattedImg1, formattedImg2); 375 | }); 376 | 377 | it('should copy a reference image if there is no difference', async () => { 378 | await looksSame.createDiff({ 379 | reference: srcPath('ref.png'), 380 | current: srcPath('same.png'), 381 | diff: this.tempName, 382 | highlightColor: '#ff00ff' 383 | }); 384 | 385 | const {equal} = await looksSame(srcPath('ref.png'), this.tempName, {strict: true}); 386 | 387 | expect(equal).to.equal(true); 388 | }); 389 | 390 | it('should create an image file with diff between two images', async () => { 391 | await looksSame.createDiff({ 392 | reference: srcPath('ref.png'), 393 | current: srcPath('different.png'), 394 | diff: this.tempName, 395 | highlightColor: '#ff00ff' 396 | }); 397 | 398 | expect(fs.existsSync(this.tempName)).to.equal(true); 399 | }); 400 | 401 | it('should ignore the differences lower then tolerance', async () => { 402 | await looksSame.createDiff({ 403 | reference: srcPath('ref.png'), 404 | current: srcPath('different.png'), 405 | diff: this.tempName, 406 | highlightColor: '#ff00ff', 407 | tolerance: 50 408 | }); 409 | 410 | const {equal} = await looksSame(srcPath('ref.png'), this.tempName, {strict: true}); 411 | 412 | expect(equal).to.equal(true); 413 | }); 414 | 415 | it('should create a proper diff', async () => { 416 | await looksSame.createDiff({ 417 | reference: srcPath('ref.png'), 418 | current: srcPath('different.png'), 419 | diff: this.tempName, 420 | highlightColor: '#ff00ff' 421 | }); 422 | 423 | const {equal} = await looksSame(imagePath('diffs/small-magenta.png'), this.tempName); 424 | 425 | expect(equal).to.equal(true); 426 | }); 427 | 428 | it('should allow to change highlight color', async () => { 429 | await looksSame.createDiff({ 430 | reference: srcPath('ref.png'), 431 | current: srcPath('different.png'), 432 | diff: this.tempName, 433 | highlightColor: '#00FF00' 434 | }); 435 | 436 | const {equal} = await looksSame(imagePath('diffs/small-green.png'), this.tempName); 437 | 438 | expect(equal).to.equal(true); 439 | }); 440 | 441 | it('should provide a default highlight color', async () => { 442 | await looksSame.createDiff({ 443 | reference: srcPath('ref.png'), 444 | current: srcPath('different.png'), 445 | diff: this.tempName 446 | }); 447 | 448 | const {equal} = await looksSame(imagePath('diffs/small-magenta.png'), this.tempName); 449 | 450 | expect(equal).to.equal(true); 451 | }); 452 | 453 | it('should allow to build diff for taller images', async () => { 454 | await looksSame.createDiff({ 455 | reference: srcPath('ref.png'), 456 | current: srcPath('tall-different.png'), 457 | diff: this.tempName, 458 | highlightColor: '#FF00FF' 459 | }); 460 | 461 | const {equal} = await looksSame(imagePath('diffs/taller-magenta.png'), this.tempName); 462 | 463 | expect(equal).to.equal(true); 464 | }); 465 | 466 | it('should allow to build diff for wider images', async () => { 467 | await looksSame.createDiff({ 468 | reference: srcPath('ref.png'), 469 | current: srcPath('wide-different.png'), 470 | diff: this.tempName, 471 | highlightColor: '#FF00FF' 472 | }); 473 | 474 | const {equal} = await looksSame(imagePath('diffs/wider-magenta.png'), this.tempName); 475 | 476 | expect(equal).to.equal(true); 477 | }); 478 | 479 | it('should use non-strict comparator by default', async () => { 480 | await looksSame.createDiff({ 481 | reference: srcPath('ref.png'), 482 | current: srcPath('different-unnoticable.png'), 483 | diff: this.tempName, 484 | highlightColor: '#FF00FF' 485 | }); 486 | 487 | const {equal} = await looksSame(srcPath('ref.png'), this.tempName); 488 | 489 | expect(equal).to.equal(true); 490 | }); 491 | 492 | it('should use strict comparator if strict option is true', async () => { 493 | await looksSame.createDiff({ 494 | reference: srcPath('ref.png'), 495 | current: srcPath('different-unnoticable.png'), 496 | diff: this.tempName, 497 | strict: true, 498 | highlightColor: '#FF00FF' 499 | }); 500 | 501 | const {equal} = await looksSame(imagePath('diffs/strict.png'), this.tempName); 502 | 503 | expect(equal).to.equal(true); 504 | }); 505 | 506 | it('should return a buffer if no diff path option is specified', async () => { 507 | const buffer = await looksSame.createDiff({ 508 | reference: srcPath('ref.png'), 509 | current: srcPath('different.png'), 510 | highlightColor: '#ff00ff' 511 | }); 512 | 513 | expect(buffer).to.be.an.instanceof(Buffer); 514 | }); 515 | 516 | it('should return a buffer equal to the diff on disk', async () => { 517 | const buffer = await looksSame.createDiff({ 518 | reference: srcPath('ref.png'), 519 | current: srcPath('different.png'), 520 | highlightColor: '#ff00ff' 521 | }); 522 | 523 | const {equal} = await looksSame(imagePath('diffs/small-magenta.png'), buffer); 524 | 525 | expect(equal).to.be.equal(true); 526 | }); 527 | 528 | describe('with comparing by areas', () => { 529 | it('should create diff image equal to reference', async () => { 530 | await looksSame.createDiff({ 531 | reference: {source: srcPath('bounding-box-ref-1.png'), boundingBox: {left: 1, top: 1, right: 4, bottom: 4}}, 532 | current: {source: srcPath('bounding-box-ref-2.png'), boundingBox: {left: 5, top: 5, right: 8, bottom: 8}}, 533 | diff: this.tempName, 534 | highlightColor: '#FF00FF' 535 | }); 536 | 537 | const {equal} = await looksSame( 538 | {source: srcPath('bounding-box-diff-1.png'), boundingBox: {left: 1, top: 1, right: 4, bottom: 4}}, 539 | this.tempName 540 | ); 541 | 542 | assert.isTrue(equal, true); 543 | }); 544 | }); 545 | 546 | describe('with antialiasing', () => { 547 | describe('if there is only diff in antialiased pixels', () => { 548 | it('should create diff image equal to reference if ignore antialiasing is not set', async () => { 549 | await looksSame.createDiff({ 550 | reference: srcPath('antialiasing-ref.png'), 551 | current: srcPath('antialiasing-actual.png'), 552 | diff: this.tempName, 553 | highlightColor: '#FF00FF' 554 | }); 555 | 556 | const {equal} = await looksSame(srcPath('antialiasing-ref.png'), this.tempName, {ignoreAntialiasing: false}); 557 | 558 | expect(equal).to.equal(true); 559 | }); 560 | 561 | it('should create diff image not equal to reference if ignore antialiasing is disabled', async () => { 562 | await looksSame.createDiff({ 563 | reference: srcPath('antialiasing-ref.png'), 564 | current: srcPath('antialiasing-actual.png'), 565 | diff: this.tempName, 566 | highlightColor: '#FF00FF', 567 | ignoreAntialiasing: false 568 | }); 569 | 570 | const {equal} = await looksSame(srcPath('antialiasing-ref.png'), this.tempName, {ignoreAntialiasing: false}); 571 | 572 | expect(equal).to.equal(false); 573 | }); 574 | }); 575 | 576 | it('should create diff image not equal to reference if there is diff not in antialised pixels', async () => { 577 | await looksSame.createDiff({ 578 | reference: srcPath('no-caret.png'), 579 | current: srcPath('1px-diff.png'), 580 | diff: this.tempName, 581 | highlightColor: '#FF00FF' 582 | }); 583 | 584 | const {equal} = await looksSame(srcPath('antialiasing-ref.png'), this.tempName); 585 | 586 | expect(equal).to.equal(false); 587 | }); 588 | }); 589 | 590 | describe('with ignoreCaret', () => { 591 | describe('if there is only diff in caret', () => { 592 | it('should create diff image equal to reference if ignore caret is not set', async () => { 593 | await looksSame.createDiff({ 594 | reference: srcPath('no-caret.png'), 595 | current: srcPath('caret.png'), 596 | diff: this.tempName, 597 | highlightColor: '#FF00FF' 598 | }); 599 | 600 | const {equal} = await looksSame(srcPath('no-caret.png'), this.tempName, {ignoreCaret: false}); 601 | 602 | expect(equal).to.equal(true); 603 | }); 604 | 605 | it('should create diff image not equal to reference if ignore caret is disabled', async () => { 606 | await looksSame.createDiff({ 607 | reference: srcPath('no-caret.png'), 608 | current: srcPath('caret.png'), 609 | diff: this.tempName, 610 | highlightColor: '#FF00FF', 611 | ignoreCaret: false 612 | }); 613 | 614 | const {equal} = await looksSame(srcPath('no-caret.png'), this.tempName, {ignoreCaret: false}); 615 | 616 | expect(equal).to.equal(false); 617 | }); 618 | }); 619 | 620 | it('should create diff image not equal to reference if there is diff not in caret', async () => { 621 | await looksSame.createDiff({ 622 | reference: srcPath('no-caret.png'), 623 | current: srcPath('1px-diff.png'), 624 | diff: this.tempName, 625 | highlightColor: '#FF00FF' 626 | }); 627 | 628 | const {equal} = await looksSame(srcPath('no-caret.png'), this.tempName); 629 | 630 | expect(equal).to.equal(false); 631 | }); 632 | }); 633 | 634 | it('should create diff image equal to reference if there are diff in antialised pixels and caret', async () => { 635 | await looksSame.createDiff({ 636 | reference: srcPath('caret+antialiasing.png'), 637 | current: srcPath('no-caret+antialiasing.png'), 638 | diff: this.tempName, 639 | highlightColor: '#FF00FF', 640 | ignoreAntialiasing: true, 641 | ignoreCaret: true 642 | }); 643 | 644 | const {equal} = await looksSame(srcPath('caret+antialiasing.png'), this.tempName, {ignoreAntialiasing: false}); 645 | 646 | expect(equal).to.equal(true); 647 | }); 648 | }); 649 | 650 | describe('colors', () => { 651 | it('should return true for same colors', () => { 652 | expect( 653 | looksSame.colors( 654 | {R: 255, G: 0, B: 0}, 655 | {R: 255, G: 0, B: 0} 656 | ) 657 | ).to.be.equal(true); 658 | }); 659 | 660 | it('should return false for different colors', () => { 661 | expect( 662 | looksSame.colors( 663 | {R: 255, G: 0, B: 0}, 664 | {R: 0, G: 0, B: 255} 665 | ) 666 | ).to.be.equal(false); 667 | }); 668 | 669 | it('should return true for similar colors', () => { 670 | expect( 671 | looksSame.colors( 672 | {R: 255, G: 0, B: 0}, 673 | {R: 254, G: 1, B: 1} 674 | ) 675 | ).to.be.equal(true); 676 | }); 677 | 678 | it('should return false for similar colors if tolerance is low enough', () => { 679 | expect( 680 | looksSame.colors( 681 | {R: 255, G: 0, B: 0}, 682 | {R: 254, G: 1, B: 1}, 683 | {tolerance: 0.0} 684 | ) 685 | ).to.be.equal(false); 686 | }); 687 | 688 | it('should return true for different colors if tolerance is high enough', () => { 689 | expect( 690 | looksSame.colors( 691 | {R: 255, G: 0, B: 0}, 692 | {R: 0, G: 0, B: 255}, 693 | {tolerance: 55.0} 694 | ) 695 | ).to.be.equal(true); 696 | }); 697 | }); 698 | 699 | describe('getDiffArea', () => { 700 | const sandbox = sinon.createSandbox(); 701 | 702 | afterEach(() => sandbox.restore()); 703 | 704 | it('should format images', async () => { 705 | sandbox.spy(utils, 'formatImages'); 706 | 707 | await looksSame.getDiffArea(srcPath('ref.png'), srcPath('same.png')); 708 | 709 | assert.calledOnceWith(utils.formatImages, srcPath('ref.png'), srcPath('same.png')); 710 | }); 711 | 712 | it('should read formatted images', async () => { 713 | const [formattedImg1, formattedImg2] = [{source: srcPath('ref.png')}, {source: srcPath('same.png')}]; 714 | sandbox.stub(utils, 'formatImages').returns([formattedImg1, formattedImg2]); 715 | sandbox.spy(utils, 'readPair'); 716 | 717 | await looksSame.getDiffArea(srcPath('ref.png'), srcPath('same.png')); 718 | 719 | assert.calledOnceWith(utils.readPair, formattedImg1, formattedImg2); 720 | }); 721 | 722 | it('should return null for similar images', async () => { 723 | const result = await looksSame.getDiffArea(srcPath('ref.png'), srcPath('same.png')); 724 | 725 | expect(result).to.equal(null); 726 | }); 727 | 728 | it('should return null for different images when tolerance is higher than difference', async () => { 729 | const result = await looksSame.getDiffArea(srcPath('ref.png'), srcPath('different.png'), {tolerance: 50}); 730 | 731 | expect(result).to.equal(null); 732 | }); 733 | 734 | it('should return correct diff area for different images', async () => { 735 | const result = await looksSame.getDiffArea(srcPath('ref.png'), srcPath('different.png'), {stopOnFirstFail: false}); 736 | 737 | expect(result.right).to.equal(49); 738 | expect(result.bottom).to.equal(39); 739 | expect(result.top).to.equal(10); 740 | expect(result.left).to.equal(0); 741 | }); 742 | 743 | it('should return sizes of a bigger image if images have different sizes', async () => { 744 | const result = await looksSame.getDiffArea(srcPath('ref.png'), srcPath('large-different.png')); 745 | 746 | expect(result).to.deep.equal({left: 0, top: 0, right: 499, bottom: 499}); 747 | }); 748 | 749 | it('should return correct diff bounds for images that differ from each other exactly by 1 pixel', async () => { 750 | const result = await looksSame.getDiffArea(srcPath('no-caret.png'), srcPath('1px-diff.png')); 751 | 752 | expect(result).to.deep.equal({left: 12, top: 6, right: 12, bottom: 6}); 753 | }); 754 | }); 755 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {formatImages, areBuffersEqual, readPair, getDiffPixelsCoords} = require('../lib/utils'); 4 | const areColorsSame = require('../lib/same-colors'); 5 | const validators = require('../lib/validators'); 6 | 7 | const path = require('path'); 8 | 9 | describe('lib/utils', () => { 10 | const sandbox = sinon.createSandbox(); 11 | 12 | beforeEach(() => { 13 | sandbox.stub(validators, 'validateImages'); 14 | }); 15 | 16 | afterEach(() => sandbox.restore()); 17 | 18 | describe('formatImages', () => { 19 | it('should validate images', () => { 20 | formatImages('img1', 'img2'); 21 | 22 | assert.calledOnceWith(validators.validateImages, 'img1', 'img2'); 23 | }); 24 | 25 | it('should not format images passed as object', () => { 26 | const [img1, img2] = [{source: 'img-path-1'}, {source: 'img-path-1'}]; 27 | const [formattedImg1, formattedImg2] = formatImages(img1, img2); 28 | 29 | assert.deepEqual(formattedImg1, img1); 30 | assert.deepEqual(formattedImg2, img2); 31 | }); 32 | 33 | it('should format images passed as buffers', () => { 34 | const [img1, img2] = [Buffer.from('img-1'), Buffer.from('img-2')]; 35 | const [formattedImg1, formattedImg2] = formatImages(img1, img2); 36 | 37 | assert.deepEqual(formattedImg1, {source: img1, boundingBox: null}); 38 | assert.deepEqual(formattedImg2, {source: img2, boundingBox: null}); 39 | }); 40 | 41 | it('should format images passed as strings', () => { 42 | const [img1, img2] = ['img-path-1', 'img-path-2']; 43 | const [formattedImg1, formattedImg2] = formatImages(img1, img2); 44 | 45 | assert.deepEqual(formattedImg1, {source: img1, boundingBox: null}); 46 | assert.deepEqual(formattedImg2, {source: img2, boundingBox: null}); 47 | }); 48 | }); 49 | 50 | describe('areBuffersEqual', () => { 51 | it('should return "false" if passed buffers contains "boundingBox" field', () => { 52 | const [img1, img2] = [ 53 | {buffer: Buffer.from('buf1')}, 54 | {buffer: Buffer.from('buf2'), boundingBox: {top: 1, left: 2, right: 3, bottom: 4}} 55 | ]; 56 | 57 | const res = areBuffersEqual(img1, img2); 58 | 59 | assert.isFalse(res); 60 | }); 61 | 62 | it('should return "false" if passed buffers are not equal', () => { 63 | const [img1, img2] = [{buffer: Buffer.from('buf1')}, {buffer: Buffer.from('buf2')}]; 64 | 65 | const res = areBuffersEqual(img1, img2); 66 | 67 | assert.isFalse(res); 68 | }); 69 | 70 | it('should return "true" if passed buffers are equal', () => { 71 | const [img1, img2] = [{buffer: Buffer.from('buf')}, {buffer: Buffer.from('buf')}]; 72 | 73 | const res = areBuffersEqual(img1, img2); 74 | 75 | assert.isTrue(res); 76 | }); 77 | }); 78 | 79 | describe('getDiffPixelsCoords', () => { 80 | const srcPath = (name) => path.join(__dirname, 'data', 'src', name); 81 | 82 | it('should return all diff area by default', async () => { 83 | const [img1, img2] = formatImages(srcPath('ref.png'), srcPath('different.png')); 84 | const {first, second} = await readPair(img1, img2); 85 | 86 | const {diffArea} = await getDiffPixelsCoords(first, second, areColorsSame); 87 | 88 | assert.deepEqual(diffArea.area, {left: 0, top: 0, right: 49, bottom: 39}); 89 | }); 90 | 91 | it('should return first non-matching pixel if asked for', async () => { 92 | const [img1, img2] = formatImages(srcPath('ref.png'), srcPath('different.png')); 93 | const {first, second} = await readPair(img1, img2); 94 | 95 | const {diffArea} = await getDiffPixelsCoords(first, second, areColorsSame, {stopOnFirstFail: true}); 96 | 97 | assert.deepEqual(diffArea.area, {left: 49, top: 0, right: 49, bottom: 0}); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /test/validators.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const {validateImages} = require('../lib/validators'); 5 | 6 | describe('lib/validators', () => { 7 | describe('validateImages', () => { 8 | it('should not throws if called with buffers', () => { 9 | assert.doesNotThrow(() => validateImages(Buffer.from('one'), Buffer.from('two'))); 10 | }); 11 | 12 | describe('should throws if', () => { 13 | it('required field "source" does not exist', () => { 14 | assert.throws(() => { 15 | return validateImages({}, {}); 16 | }, TypeError, 'Field "source" does not exist'); 17 | }); 18 | 19 | it('required field "boundingBox" does not exist', () => { 20 | assert.throws(() => { 21 | return validateImages( 22 | {source: 'image-path'}, 23 | {source: 'image-path'}, 24 | ); 25 | }, TypeError, 'Field "boundingBox" does not exist'); 26 | }); 27 | 28 | ['left', 'top', 'right', 'bottom'].forEach((field) => { 29 | it(`required field "${field}" does not exist in "boundingBox"`, () => { 30 | assert.throws(() => { 31 | return validateImages( 32 | {source: 'image-path', boundingBox: _.omit({left: 0, top: 0, right: 0, bottom: 0}, field)}, 33 | {source: 'image-path', boundingBox: _.omit({left: 0, top: 0, right: 0, bottom: 0}, field)} 34 | ); 35 | }, TypeError, `Field "${field}" does not exist`); 36 | }); 37 | }); 38 | 39 | it('"left" coordinate in "boundingBox" field greater than "right"', () => { 40 | assert.throws(() => { 41 | return validateImages( 42 | {source: 'image-path', boundingBox: {left: 1, top: 0, right: 0, bottom: 0}}, 43 | {source: 'image-path', boundingBox: {left: 1, top: 0, right: 0, bottom: 0}} 44 | ); 45 | }, TypeError, '"left" coordinate in "boundingBox" field cannot be greater than "right"'); 46 | }); 47 | 48 | it('"top" coordinate in "boundingBox" field greater than "bottom"', () => { 49 | assert.throws(() => { 50 | return validateImages( 51 | {source: 'image-path', boundingBox: {left: 0, top: 1, right: 0, bottom: 0}}, 52 | {source: 'image-path', boundingBox: {left: 0, top: 1, right: 0, bottom: 0}} 53 | ); 54 | }, TypeError, '"top" coordinate in "boundingBox" field cannot be greater than "bottom"'); 55 | }); 56 | }); 57 | }); 58 | }); 59 | --------------------------------------------------------------------------------