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.
This commit is contained in:
Powei Feng
2025-09-15 17:27:42 -07:00
committed by GitHub
parent 6ac5f26bb5
commit a36ad004b7
2 changed files with 347 additions and 61 deletions

View File

@@ -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`
<div>
<radio-button-group id="${name}" .choices=${choices} value=${current}></radio-button-group>
<tiff-viewer class="viewer" fileurl="${url}"></tiff-viewer>
</div>
`;
}
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`
<div class="viewer-container">
${v1}
${v2}
</div>
<expanded-comparison-result
.left=${this.content}
.right=${this.content}
leftViewType="golden"
rightViewType="rendered"
.tests=${this.tests}
?disableDropdowns=${true}>
</expanded-comparison-result>
`;
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`<div class="selector">${current.name} (${viewType})</div>`;
}
return html`
<select class="selector" @change=${onSelect}>
${choices.map((c) => html`<option value=${c.name} ?selected=${c.name == current.name}>${c.name}</option>`)}
</select>
`;
}
return html`
<div style="flex: 1; margin: 0 5px;">
${dropdown()}
<tiff-viewer id="viewer-${name}" class="viewer"
name="${current.name}"
fileurl="${url}"></tiff-viewer>
</div>
`;
}
_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`
<div class="main-container">
<div class="viewer-container">
${v1}
<div style="flex: 1; ${diffStyle}">
<canvas id="diffCanvas"></canvas>
</div>
${v2}
</div>
<div class="control" style="${diffStyle}">
<div>
<div>Difference Multiplier</div>
<input type="range" min="1" max="100" value="1" id="diffMultiplier" @input=${onMultiplierChange}>
</div>
</div>
</div>
`;
}
}
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`<tiff-viewer style="max-width:100px" fileurl="${url}"></tiff-viewer>`;
};
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`
<div class="test-item" @click="${()=>this._onClick(t)}">
<div class="test-item" @click="${(e)=>this._onClick(t, e)}" style=${selected ? "background: #eee" : ""}>
<span class="test-label">${printName}</span>
${tiff}
</div>
`;
});
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`
<div class="test-item" @click="${()=>this._onClick(t)}">
<span class="test-label">${printName}</span>
<div class="test-item" @click="${(e)=>this._onClick(t, e)}" >
<span class="test-label" title=${t.name}>${printName}</span>
<div class="failed-tiffs">
${tiffs}
</div>
@@ -269,14 +523,30 @@ class App extends LitElement {
if (this.dialogContent.result == 'ok') {
return html`<expanded-passed-result .content=${this.dialogContent}></expanded-passed-result>`;
}
return html`<expanded-failed-result .content=${this.dialogContent}></expanded-failed-result>`;
return html`<expanded-failed-result .content=${this.dialogContent} .tests=${this.tests}></expanded-failed-result>`;
})();
dialogHeader = this.dialogContent.name;
} else if (this.comparisonContent) {
dialogSlot = html`
<expanded-comparison-result
.left=${this.comparisonContent.left}
.right=${this.comparisonContent.right}
.tests=${this.tests}>
</expanded-comparison-result>
`;
dialogHeader = 'Comparison';
}
const compareButton = () => {
if (!this.compareMode) {
return html``;
}
return html`<button class="compare-button" @click=${this._onCompare} ?disabled=${this.selectedTests.length != 2}>Compare</button>`;
}
return html`
<div class="app">
<div class="results-container" style="background:#f5a5a4">
<div class="results-container" style="background:#f5a5a4; ${this.compareMode ? 'display:none' : ''}">
<span class="container-label">Failed</span>
<div class="results">
${failedTiffs}
@@ -288,6 +558,11 @@ class App extends LitElement {
${passedTiffs}
</div>
</div>
<label>
<input type="checkbox" .checked=${this.compareMode} @change=${this._onCompareModeChange}>
Compare Mode
</label>
${compareButton()}
</div>
<modal-dialog id="dialog">
<div class="dialog-header" slot="header">${dialogHeader}</div>

View File

@@ -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();