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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user