From a36ad004b78a5d551b6c867a2afabb704ed9e62c Mon Sep 17 00:00:00 2001 From: Powei Feng Date: Mon, 15 Sep 2025 17:27:42 -0700 Subject: [PATCH] renderdiff: add ability to diff arbitrary rendered images (#9211) Add a way to show the difference between two rendererd image. This is a convenience feature to see the differences between backends. Refactor the ExpandedFailedResult to use this new view. --- test/renderdiff/src/viewer_html/app.js | 397 +++++++++++++++--- .../renderdiff/src/viewer_html/tiff-viewer.js | 11 + 2 files changed, 347 insertions(+), 61 deletions(-) diff --git a/test/renderdiff/src/viewer_html/app.js b/test/renderdiff/src/viewer_html/app.js index 677ea9a7f9..b78088cfbb 100644 --- a/test/renderdiff/src/viewer_html/app.js +++ b/test/renderdiff/src/viewer_html/app.js @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { LitElement, html, css } from "https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js"; +import { LitElement, html, css, repeat } from "https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js"; import './tools.js'; import './tiff-viewer.js'; @@ -32,6 +32,12 @@ const DIFF_VIEW = 'diff' const GOLDEN_VIEW = 'golden' const RENDERED_VIEW = 'rendered' +const RES_EQUAL = "mismatched dimensions"; +const RES_MISMATCHED_DIMENSIONS = "mismatched dimensions"; +const RES_DIFFERENT_PIXELS = "different pixels"; +const RES_NOT_READY = "not ready"; +const FAILED_POSTIFX = ".failed"; + class ExpandedPassedResult extends LitElement { static get properties() { return { @@ -58,67 +64,264 @@ class ExpandedFailedResult extends LitElement { static get properties() { return { content: { type: Object, attribute: 'content' }, - leftViewSelected: { type: String }, - rightViewSelected: { type: String }, + tests: { type: Array }, }; } - static styles = css` - .viewer-container { - display: flex; - flex-direction: row; - } - .viewer { - margin: 0 5px; - } - `; - - constructor() { - super(); - this.content = null; - this.leftViewSelected = GOLDEN_VIEW; - this.rightViewSelected = DIFF_VIEW; - this.addEventListener('radio-change', (ev) => { - if (ev.detail.radioId == 'right') { - this.rightViewSelected = ev.detail.value; - } - if (ev.detail.radioId == 'left') { - this.leftViewSelected = ev.detail.value; - } - }); - } - - _viewer(name, choices, current) { - const url = { - [GOLDEN_VIEW]: getGoldenUrl, - [RENDERED_VIEW]: getCompUrl, - [DIFF_VIEW]: getDiffUrl, - }[current](this.content); - return html` -
- - -
- `; - } - render() { if (!this.content) { return html``; } - const v1 = this._viewer("left", [GOLDEN_VIEW, RENDERED_VIEW], this.leftViewSelected); - const v2 = this._viewer("right", [RENDERED_VIEW, DIFF_VIEW], this.rightViewSelected); return html` -
- ${v1} - ${v2} -
+ + `; - return html``; } } customElements.define('expanded-failed-result', ExpandedFailedResult); +class ExpandedComparisonResult extends LitElement { + static get properties() { + return { + left: { type: Object }, + right: { type: Object }, + tests: { type: Array }, + diffResult: { type: Object }, + showDiff: {type: Boolean }, + leftViewType: { type: String }, + rightViewType: { type: String }, + disableDropdowns: { type: Boolean }, + }; + } + + static styles = css` + .main-container { + display: flex; + flex-direction: column; + align-items: center; + } + .viewer-container { + display: flex; + flex-direction: row; + } + #diffCanvas { + width: 100%; + height: 100%; + margin-top: 35px; + margin-bottom: 5px; + } + .selector { + margin: 8px 0; + } + `; + + constructor() { + super(); + this.left = null; + this.right = null; + this.tests = []; + this.showDiff = false; + this.leftImageLoaded = false; + this.rightImageLoaded = false; + this.leftViewType = 'golden'; + this.rightViewType = 'golden'; + this.disableDropdowns = false; + + this.addEventListener( + 'image-loaded', + (ev) => { + if (ev.detail.name == this.left.name) { + this.leftImageLoaded = true; + } + if (ev.detail.name == this.right.name) { + this.rightImageLoaded = true; + } + if (this.leftImageLoaded && this.rightImageLoaded) { + this._triggerDiff(); + } + } + ); + } + + _viewer(name, choices, current, viewType) { + const url = viewType == 'rendered' ? getCompUrl(current) : getGoldenUrl(current); + const onSelect = (ev) => { + const testName = ev.target.value; + const test = this.tests.find((t) => t.name == testName); + if (name == 'left') { + this.left = test; + this.leftImageLoaded = false; + } else { + this.right = test; + this.rightImageLoaded = false; + } + this.showDiff = false; + }; + + const dropdown = () => { + if (this.disableDropdowns) { + return html`
${current.name} (${viewType})
`; + } + return html` + + `; + } + + return html` +
+ ${dropdown()} + +
+ `; + } + + _computeDiff() { + const tiffViewerLeft = this.shadowRoot.querySelector('#viewer-left'); + const tiffViewerRight = this.shadowRoot.querySelector('#viewer-right'); + + if (!tiffViewerLeft || !tiffViewerRight) { + return { + "result": RES_NOT_READY, + }; + } + + const canvasLeft = tiffViewerLeft.shadowRoot.querySelector('canvas'); + const canvasRight = tiffViewerRight.shadowRoot.querySelector('canvas'); + + if (!canvasLeft || !canvasRight) { + return { + "result": RES_NOT_READY, + }; + } + + const ctxLeft = canvasLeft.getContext('2d'); + const ctxRight = canvasRight.getContext('2d'); + const imgLeft = ctxLeft.getImageData(0, 0, canvasLeft.width, canvasLeft.height); + const imgRight = ctxRight.getImageData(0, 0, canvasRight.width, canvasRight.height); + + if (imgLeft.width !== imgRight.width || imgLeft.height !== imgRight.height) { + console.error("Images have different dimensions"); + return { + "result": RES_MISMATCHED_DIMENSIONS, + "explanation": "Images have different dimensions " + + "left=(" + imgLeft.width + ", " + imgLeft.height + ") " + + "right=(" + imgRight.width + ", " + imgRight.height + ")", + }; + } + + const imgDiff = new Uint8ClampedArray(imgLeft.width * imgLeft.height * 4); + const maxDiff = [0, 0, 0, 0]; + for (let i = 0; i < imgLeft.data.length; i += 4) { + for (let j = 0; j < 4; j++) { + maxDiff[j] = Math.max( + maxDiff[j], + Math.min(255, Math.abs(imgLeft.data[i + j] - imgRight.data[i + j])) + ); + imgDiff[i + j] = Math.abs(imgLeft.data[i + j] - imgRight.data[i + j]); + } + imgDiff[i + 3] = 255; + } + + if (maxDiff[0] == 0 && + maxDiff[1] == 0 && + maxDiff[2] == 0 && + maxDiff[3] == 0) { + return { + "result": RES_EQUAL, + "explanation": "Equal", + "dim": {"width": imgLeft.width, "height": imgLeft.height }, + } + } + + return { + "result": RES_DIFFERENT_PIXELS, + "explanation": "Images are different", + "dim": {"width": imgLeft.width, "height": imgLeft.height }, + "maxDiff": maxDiff, + "diffImg": imgDiff, + }; + } + + _triggerDiff() { + const diff = this._computeDiff(); + if (diff.result == RES_DIFFERENT_PIXELS) { + this.diffResult = diff; + this.showDiff = true; + } + } + + updated(props) { + if (this.showDiff && this.diffResult) { + const mult = this.shadowRoot.querySelector('#diffMultiplier').value; + this._updateDiffCanvas(this.diffResult, mult); + } + } + + _updateDiffCanvas(diffResult, mult) { + const diffCanvas = this.shadowRoot.querySelector('#diffCanvas'); + const diffCtx = diffCanvas.getContext('2d'); + diffCanvas.width = diffResult.dim.width; + diffCanvas.height = diffResult.dim.height; + + // Create a fresh copy of the original diff data to avoid mutation. + const diffImgCopy = diffResult.diffImg.slice(); + + // Modify the copy, not the original. + for (let i = 0; i < diffImgCopy.length; i += 4) { + for (let j = 0; j < 3; j++) { + diffImgCopy[i + j] = Math.min(255, mult * diffImgCopy[i + j]); + } + diffImgCopy[i + 3] = 255; // Ensure alpha is always 255 + } + + // Create the ImageData from the modified copy. + const imgData = new ImageData(diffImgCopy, diffResult.dim.width, diffResult.dim.height); + diffCtx.putImageData(imgData, 0, 0); + } + + render() { + if (!this.left || !this.right) { + return html``; + } + + const diffStyle = !this.showDiff ? "display:none;" : "display:flex; flex-direction: column;"; + const v1 = this._viewer("left", this.tests, this.left, this.leftViewType); + const v2 = this._viewer("right", this.tests, this.right, this.rightViewType); + const onMultiplierChange = (ev) => { + this._updateDiffCanvas(this.diffResult, ev.target.value); + }; + return html` +
+
+ ${v1} +
+ +
+ ${v2} +
+
+
+
Difference Multiplier
+ +
+
+
+ `; + } +} +customElements.define('expanded-comparison-result', ExpandedComparisonResult); + + class App extends LitElement { static styles = css` .test-label { @@ -146,8 +349,9 @@ class App extends LitElement { padding-top: 20px; } .results { - display: inline-flex; + display: flex; flex-direction: row; + flex-wrap: wrap; } .results-container { display: inline-flex; @@ -157,7 +361,7 @@ class App extends LitElement { background: #cbf1c9; padding: 5px; align-items: center; - margin: 10px 0; + margin: 10px 10px; } .container-label { margin-bottom: 10px; @@ -180,11 +384,27 @@ class App extends LitElement { font-size: 18px; margin-bottom: 10px; } + .compare-button { + margin: 10px; + padding: 10px; + border-radius: 5px; + background: #4285F4; + color: white; + cursor: pointer; + } + .compare-button[disabled] { + background: #ccc; + cursor: not-allowed; + margin: 10px 0; + } `; static properties = { tests: {type: Array}, dialogContent: {type: Object}, + selectedTests: {type: Array}, + comparisonContent: {type: Object}, + compareMode: {type: Boolean}, }; async _init() { @@ -197,42 +417,76 @@ class App extends LitElement { super(); this.tests = []; this.dialogContent = null; + this.selectedTests = []; + this.comparisonContent = null; + this.compareMode = false; this._init(); this.addEventListener('dialog-closed', () => { this.dialogContent = null; + this.comparisonContent = null; }); } updated(props) { - if (props.has('dialogContent')) { + if (props.has('dialogContent') || props.has('comparisonContent')) { let dialog = this.shadowRoot.querySelector("#dialog"); - dialog.open = !!this.dialogContent; + dialog.open = !!this.dialogContent || !!this.comparisonContent; } } - _onClick(testDetail) { + _onClick(testDetail, ev) { + if (this.compareMode) { + const index = this.selectedTests.indexOf(testDetail); + if (index > -1) { + this.selectedTests.splice(index, 1); + } else { + if (this.selectedTests.length >= 2) { + this.selectedTests.shift(); + } + this.selectedTests.push(testDetail); + } + this.requestUpdate(); + return; + } this.dialogContent = testDetail; this.shadowRoot.querySelector("#dialog").open = true; } + _onCompare() { + if (this.selectedTests.length == 2) { + this.comparisonContent = { + left: this.selectedTests[0], + right: this.selectedTests[1], + }; + } + } + + _onCompareModeChange(e) { + this.compareMode = e.target.checked; + if (!this.compareMode) { + this.selectedTests = []; + } + } + render() { let passed = this.tests.filter((t) => t.result == 'ok'); let failed = this.tests.filter((t) => t.result != 'ok'); const singleTiff = (url) => { return html``; }; - const buildTiffs = (ts) => ts.map((t) => { + const buildTiffs = (ts) => repeat(ts, (t) => t.name, (t) => { const printName = t.name.replace('.tif', ''); const tiff = singleTiff(getGoldenUrl(t)); + const selected = this.selectedTests.includes(t); return html` -
+
${printName} ${tiff}
`; }); - const buildFailedTiffs = (ts) => ts.map((t) => { + const buildFailedTiffs = (ts) => repeat(ts, (t) => t.name, (t) => { const printName = t.name.replace('.tif', ''); const goldenUrl = getGoldenUrl(t); const compUrl = getCompUrl(t); @@ -251,8 +505,8 @@ class App extends LitElement { ].map((a) => [singleTiff(a[0]), a[1]]) .map((a) => wrap(...a)); return html` -
- ${printName} +
+ ${printName}
${tiffs}
@@ -269,14 +523,30 @@ class App extends LitElement { if (this.dialogContent.result == 'ok') { return html``; } - return html``; + return html``; })(); dialogHeader = this.dialogContent.name; + } else if (this.comparisonContent) { + dialogSlot = html` + + + `; + dialogHeader = 'Comparison'; + } + + const compareButton = () => { + if (!this.compareMode) { + return html``; + } + return html``; } return html`
-
+
Failed
${failedTiffs} @@ -288,6 +558,11 @@ class App extends LitElement { ${passedTiffs}
+ + ${compareButton()}
${dialogHeader}
diff --git a/test/renderdiff/src/viewer_html/tiff-viewer.js b/test/renderdiff/src/viewer_html/tiff-viewer.js index f56bd5cf96..6c46082594 100644 --- a/test/renderdiff/src/viewer_html/tiff-viewer.js +++ b/test/renderdiff/src/viewer_html/tiff-viewer.js @@ -29,11 +29,13 @@ export class TiffViewer extends LitElement { static properties = { fileurl: {type: String, attribute: 'fileurl'}, + name: {type: String, attribute: 'name'}, }; constructor() { super(); this.fileurl = null; + this.name = null; } render() { @@ -78,6 +80,15 @@ export class TiffViewer extends LitElement { const imageData = new ImageData(new Uint8ClampedArray(rgba), firstImage.width, firstImage.height); ctx.putImageData(imageData, 0, 0); + + this.dispatchEvent(new CustomEvent('image-loaded', { + bubbles: true, + composed: true, + detail: { + name: this.name, + img: imageData, + } + })); } catch (error) { console.error('Error processing TIFF file:', error); this._clearCanvas();