renderdiff: add viewer for image differences (#8768)

- Modify the compare script to output more details of a
   comparison. This will include the source/golden directory,
   the comparison directory (the new renderings), and a file
   path to difference images if the golden does not match
   the rendered image.
 - The image_diff script can now output a TIFF that is the
   difference of two input TIFFs.
 - Add a viewer for examining the differences between rendered
   output and golden images.
 - The viewer consists of a simple server of web API endpoints
   for querying difference results (along with rendered images
   in TIFF).
 - And a web-based (html + lit-element) UI for looking at the
   rendered images and differences.
This commit is contained in:
Powei Feng
2025-06-02 19:37:46 +00:00
committed by GitHub
parent 60036c75fe
commit cd22478e4f
11 changed files with 821 additions and 37 deletions
+299
View File
@@ -0,0 +1,299 @@
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// 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 './tools.js';
import './tiff-viewer.js';
function getGoldenUrl(testResult) {
return '/g/' + testResult.name;
}
function getCompUrl(testResult) {
return '/c/' + testResult.name;
}
function getDiffUrl(testResult) {
return '/d/' + testResult.diff;
}
const DIFF_VIEW = 'diff'
const GOLDEN_VIEW = 'golden'
const RENDERED_VIEW = 'rendered'
class ExpandedPassedResult extends LitElement {
static get properties() {
return {
content: { type: Object, attribute: 'content' },
};
}
constructor() {
super();
this.content = null;
}
render() {
if (this.content) {
const url = getGoldenUrl(this.content);
return html`<tiff-viewer fileurl="${url}"></tiff-viewer>`;
}
return html``;
}
}
customElements.define('expanded-passed-result', ExpandedPassedResult);
class ExpandedFailedResult extends LitElement {
static get properties() {
return {
content: { type: Object, attribute: 'content' },
leftViewSelected: { type: String },
rightViewSelected: { type: String },
};
}
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>
`;
return html``;
}
}
customElements.define('expanded-failed-result', ExpandedFailedResult);
class App extends LitElement {
static styles = css`
.test-label {
margin-bottom: 10px;
font-size: 12px;
}
.test-item {
display: inline-flex;
flex-direction: column;
border: 1px solid black;
border-radius: 10px;
padding: 10px;
align-items: center;
margin: 5px;
}
.test-item:hover {
box-shadow: 2px 3px 2px 0px rgba(40, 40, 40, .5);
cursor: pointer;
}
.app {
display: flex;
flex-direction: column;
width: 100%;
align-items: center;
padding-top: 20px;
}
.results {
display: inline-flex;
flex-direction: row;
}
.results-container {
display: inline-flex;
flex-direction: column;
border: 1px solid black;
border-radius: 10px;
background: #cbf1c9;
padding: 5px;
align-items: center;
margin: 10px 0;
}
.container-label {
margin-bottom: 10px;
}
.failed-tiffs {
display: inline-flex;
flex-direction: row;
}
.tiff-wrapper {
margin: 0 4px;
display: inline-flex;
flex-direction: column;
align-items: center;
}
.tiff-wrapper-label {
margin: 4px 0;
font-size: 10px;
}
.dialog-header {
font-size: 18px;
margin-bottom: 10px;
}
`;
static properties = {
tests: {type: Array},
dialogContent: {type: Object},
};
async _init() {
const config = await ((await fetch("/r/")).json());
config['results'] = config['results'].sort((a, b) => a.name < b.name ? -1 : (a.name > b.name ? 1 : 0));
this.tests = config['results']
}
constructor() {
super();
this.tests = [];
this.dialogContent = null;
this._init();
this.addEventListener('dialog-closed', () => {
this.dialogContent = null;
});
}
updated(props) {
if (props.has('dialogContent')) {
let dialog = this.shadowRoot.querySelector("#dialog");
dialog.open = !!this.dialogContent;
}
}
_onClick(testDetail) {
this.dialogContent = testDetail;
this.shadowRoot.querySelector("#dialog").open = true;
}
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 printName = t.name.replace('.tif', '');
const tiff = singleTiff(getGoldenUrl(t));
return html`
<div class="test-item" @click="${()=>this._onClick(t)}">
<span class="test-label">${printName}</span>
${tiff}
</div>
`;
});
const buildFailedTiffs = (ts) => ts.map((t) => {
const printName = t.name.replace('.tif', '');
const goldenUrl = getGoldenUrl(t);
const compUrl = getCompUrl(t);
const diffUrl = getDiffUrl(t);
const wrap = (tif, label) => {
return html`
<div class="tiff-wrapper">
${tif}
<div class="tiff-wrapper-label">${label}</div>
</div>`;
}
const tiffs = [
[goldenUrl, 'golden'],
[compUrl, 'rendered'],
[diffUrl, 'diff']
].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="failed-tiffs">
${tiffs}
</div>
</div>
`;
});
const passedTiffs = buildTiffs(passed);
const failedTiffs = buildFailedTiffs(failed);
let dialogSlot = null;
let dialogHeader = '';
if (this.dialogContent) {
dialogSlot = (() => {
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>`;
})();
dialogHeader = this.dialogContent.name;
}
return html`
<div class="app">
<div class="results-container" style="background:#f5a5a4">
<span class="container-label">Failed</span>
<div class="results">
${failedTiffs}
</div>
</div>
<div class="results-container">
<span class="container-label">Passed</span>
<div class="results">
${passedTiffs}
</div>
</div>
</div>
<modal-dialog id="dialog">
<div class="dialog-header" slot="header">${dialogHeader}</div>
${dialogSlot}
</modal-dialog>
`;
}
}
customElements.define('diff-app', App);