From 772a620d35c853a6cad921b827a39d53c39e6ce4 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 15 Aug 2023 09:44:40 -0700 Subject: [PATCH 1/5] re-encode image data --- package.json | 1 + test/plot.js | 22 +++++++++++++--------- yarn.lock | 5 +++++ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index f42de6b215..88a189cd38 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-terser": "^0.4.0", "@types/d3": "^7.4.0", + "@types/node": "^20.5.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "canvas": "^2.0.0", diff --git a/test/plot.js b/test/plot.js index 379a4dc39c..c4efaf4490 100644 --- a/test/plot.js +++ b/test/plot.js @@ -1,4 +1,5 @@ import {promises as fs} from "fs"; +import {loadImage} from "canvas"; import * as path from "path"; import beautify from "js-beautify"; import assert from "./assert.js"; @@ -38,10 +39,8 @@ for (const [name, plot] of Object.entries(plots)) { } // node-canvas won’t produce the same output on different architectures, so - // until we have a way to normalize the output, we need to ignore the - // generated image data during comparison. But you can still review the - // generated output visually and hopefully it’ll be correct. - const equal = process.env.CI === "true" ? stripImageData(actual) === stripImageData(expected) : actual === expected; + // we parse and re-encode images before comparison. + const equal = (await normalizeImageData(actual)) === (await normalizeImageData(expected)); if (equal) { if (process.env.CI !== "true") { @@ -108,9 +107,14 @@ function reindexClip(root) { } } -function stripImageData(string) { - return string.replace( - /data:image\/png;base64,[^"]+/g, - "data:image/svg+xml,%3Csvg width='15' height='15' viewBox='0 0 20 20' style='background-color: white' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0h10v10H0zm10 10h10v10H10z' fill='%23f4f4f4' fill-rule='evenodd'/%3E%3C/svg%3E" - ); +async function normalizeImageData(string) { + const re = /data:image\/png;base64,[^"]+/g; + let replaced = string; + let match; + let i = 0; + while ((match = re.exec(string))) { + const image = await loadImage(match[0]); + replaced = `${string.slice(i, match.index)}${image.src}${string.slice((i = re.lastIndex))}`; + } + return replaced; } diff --git a/yarn.lock b/yarn.lock index 3cf56e0aac..998c402886 100644 --- a/yarn.lock +++ b/yarn.lock @@ -820,6 +820,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== +"@types/node@^20.5.0": + version "20.5.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.5.0.tgz#7fc8636d5f1aaa3b21e6245e97d56b7f56702313" + integrity sha512-Mgq7eCtoTjT89FqNoTzzXg2XvCi5VMhRV6+I2aYanc6kQCBImeNaAYRs/DyoVqk1YEUJK5gN9VO7HRIdz4Wo3Q== + "@types/resolve@1.20.2": version "1.20.2" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" From 3b65b3b2856c106b940dae96550d6d1ba59547de Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 15 Aug 2023 09:49:45 -0700 Subject: [PATCH 2/5] adopt canvas.toDataURL --- test/plot.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/plot.js b/test/plot.js index c4efaf4490..801724f310 100644 --- a/test/plot.js +++ b/test/plot.js @@ -1,5 +1,5 @@ import {promises as fs} from "fs"; -import {loadImage} from "canvas"; +import {createCanvas, loadImage} from "canvas"; import * as path from "path"; import beautify from "js-beautify"; import assert from "./assert.js"; @@ -114,7 +114,10 @@ async function normalizeImageData(string) { let i = 0; while ((match = re.exec(string))) { const image = await loadImage(match[0]); - replaced = `${string.slice(i, match.index)}${image.src}${string.slice((i = re.lastIndex))}`; + const canvas = createCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + replaced = `${string.slice(i, match.index)}${canvas.toDataURL()}${string.slice((i = re.lastIndex))}`; } return replaced; } From e6944bfcdaa18a6d2c875d7e5749dd4e48959a41 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 15 Aug 2023 11:31:58 -0700 Subject: [PATCH 3/5] compare image data --- test/plot.js | 46 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/test/plot.js b/test/plot.js index 801724f310..bf562401d3 100644 --- a/test/plot.js +++ b/test/plot.js @@ -39,8 +39,8 @@ for (const [name, plot] of Object.entries(plots)) { } // node-canvas won’t produce the same output on different architectures, so - // we parse and re-encode images before comparison. - const equal = (await normalizeImageData(actual)) === (await normalizeImageData(expected)); + // we parse and compare pixel values instead of the encoded output. + const equal = stripImages(actual) === stripImages(expected) && (await compareImages(actual, expected)); if (equal) { if (process.env.CI !== "true") { @@ -107,17 +107,35 @@ function reindexClip(root) { } } -async function normalizeImageData(string) { - const re = /data:image\/png;base64,[^"]+/g; - let replaced = string; - let match; - let i = 0; - while ((match = re.exec(string))) { - const image = await loadImage(match[0]); - const canvas = createCanvas(image.width, image.height); - const context = canvas.getContext("2d"); - context.drawImage(image, 0, 0); - replaced = `${string.slice(i, match.index)}${canvas.toDataURL()}${string.slice((i = re.lastIndex))}`; +const imageRe = /data:image\/png;base64,[^"]+/g; + +function stripImages(string) { + return string.replace(imageRe, ""); +} + +async function compareImages(a, b, epsilon = 1) { + const reA = new RegExp(imageRe, "g"); + const reB = new RegExp(imageRe, "g"); + let matchA; + let matchB; + while (((matchA = reA.exec(a)), (matchB = reB.exec(b)))) { + const imageA = await getImageData(matchA[0]); + const imageB = await getImageData(matchB[0]); + const {width, height} = imageA; + if (width !== imageB.width || height !== imageB.height) return false; + for (let i = 0, n = imageA.data.length; i < n; ++i) { + if (Math.abs(imageA.data[i] - imageB.data[i]) > epsilon) { + return false; + } + } } - return replaced; + return true; +} + +async function getImageData(url) { + const image = await loadImage(url); + const canvas = createCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + return context.getImageData(0, 0, image.width, image.height); } From 7bb51420b31c27c1d033f75bdd72cc51373b270f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 15 Aug 2023 11:34:52 -0700 Subject: [PATCH 4/5] parallelize getImageData --- test/plot.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/plot.js b/test/plot.js index bf562401d3..12e712bfb8 100644 --- a/test/plot.js +++ b/test/plot.js @@ -119,8 +119,7 @@ async function compareImages(a, b, epsilon = 1) { let matchA; let matchB; while (((matchA = reA.exec(a)), (matchB = reB.exec(b)))) { - const imageA = await getImageData(matchA[0]); - const imageB = await getImageData(matchB[0]); + const [imageA, imageB] = await Promise.all([getImageData(matchA[0]), getImageData(matchB[0])]); const {width, height} = imageA; if (width !== imageB.width || height !== imageB.height) return false; for (let i = 0, n = imageA.data.length; i < n; ++i) { From f600cafbc7f8a2674bb9e2f40bd3429161d40ed8 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 15 Aug 2023 11:55:14 -0700 Subject: [PATCH 5/5] statistical image comparison --- test/plot.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/plot.js b/test/plot.js index 12e712bfb8..82ad028ca1 100644 --- a/test/plot.js +++ b/test/plot.js @@ -1,5 +1,6 @@ import {promises as fs} from "fs"; import {createCanvas, loadImage} from "canvas"; +import {max, mean, quantile} from "d3"; import * as path from "path"; import beautify from "js-beautify"; import assert from "./assert.js"; @@ -113,7 +114,7 @@ function stripImages(string) { return string.replace(imageRe, ""); } -async function compareImages(a, b, epsilon = 1) { +async function compareImages(a, b) { const reA = new RegExp(imageRe, "g"); const reB = new RegExp(imageRe, "g"); let matchA; @@ -122,11 +123,10 @@ async function compareImages(a, b, epsilon = 1) { const [imageA, imageB] = await Promise.all([getImageData(matchA[0]), getImageData(matchB[0])]); const {width, height} = imageA; if (width !== imageB.width || height !== imageB.height) return false; - for (let i = 0, n = imageA.data.length; i < n; ++i) { - if (Math.abs(imageA.data[i] - imageB.data[i]) > epsilon) { - return false; - } - } + const E = imageA.data.map((a, i) => Math.abs(a - imageB.data[i])); + if (!(quantile(E, 0.95) <= 1)) return false; // at least 95% with almost no error + if (!(mean(E) < 0.1)) return false; // no more than 0.1 average error + if (!(max(E) < 10)) return false; // no more than 10 maximum error } return true; }