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);
@@ -0,0 +1,36 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Diff Viewer</title>
<script type="module" src="./app.js"></script>
<script src="https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.min.js"></script>
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700" rel="stylesheet">
<style>
html, body {
height: 100%;
margin: 0;
font-family: "Open Sans";
}
</style>
</head>
<body>
<diff-app></diff-app>
</body>
</html>
@@ -0,0 +1,96 @@
// 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";
// Generated by Gemini with some modifications
export class TiffViewer extends LitElement {
static styles = css`
:host {
display: block;
}
canvas {
border: 1px solid #ccc;
width: 100%;
height: 100%;
}
`;
static properties = {
fileurl: {type: String, attribute: 'fileurl'},
};
constructor() {
super();
this.fileurl = null;
}
render() {
return html`<canvas id="tiffCanvas"></canvas>`;
}
updated(props) {
if (props.has('fileurl') && this.fileurl) {
this._updateImage(this.fileurl);
}
}
async _updateImage(fileurl) {
const fileblob = await ((await fetch(this.fileurl)).arrayBuffer());
const canvas = this.shadowRoot.getElementById('tiffCanvas');
const ctx = canvas.getContext('2d');
try {
const arrayBuffer = fileblob;
const ifds = UTIF.decode(arrayBuffer); // Parse TIFF data
if (!ifds || ifds.length === 0) {
console.error('Could not decode TIFF file. No image data found.');
this._clearCanvas();
return;
}
// By default, render the first image in the TIFF (IFD)
const firstImage = ifds[0];
UTIF.decodeImage(arrayBuffer, firstImage, ifds); // Decode the actual pixel data
const rgba = UTIF.toRGBA8(firstImage); // Convert to RGBA
canvas.width = firstImage.width;
canvas.height = firstImage.height;
// The default mode would set alpha to 1 so that RGB differences would be displayed as non-transparent
for (let i = 0; i < firstImage.width; i++) {
for (let j = 0; j < firstImage.height; j++) {
rgba[(j * firstImage.width * 4) + (i * 4) + 3] = 255;
}
}
const imageData = new ImageData(new Uint8ClampedArray(rgba), firstImage.width, firstImage.height);
ctx.putImageData(imageData, 0, 0);
} catch (error) {
console.error('Error processing TIFF file:', error);
this._clearCanvas();
}
}
_clearCanvas() {
const canvas = this.shadowRoot.getElementById('tiffCanvas');
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
}
customElements.define('tiff-viewer', TiffViewer);
+225
View File
@@ -0,0 +1,225 @@
// 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";
// Generated by Gemini
export class RadioButtonGroup extends LitElement {
static styles = css`
:host {
display: block;
font-family: sans-serif;
}
.radio-group-container {
display: flex;
flex-direction: row;
}
label {
display: flex;
align-items: center;
margin-bottom: 8px;
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: background-color 0.2s ease-in-out;
}
label:hover {
background-color: #f0f0f0;
}
input[type="radio"] {
margin-right: 8px;
cursor: pointer;
/* Custom radio button appearance */
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border: 2px solid #ccc;
border-radius: 50%;
outline: none;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
input[type="radio"]:checked {
border-color: #656565;
background-color: #656565; /* Optional: fill color when checked */
}
input[type="radio"]:checked::before {
content: '';
display: block;
width: 8px;
height: 8px;
margin: 3px; /* Adjust to center the dot */
background-color: white;
border-radius: 50%;
}
input[type="radio"]:focus {
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.label-text {
font-size: 1rem;
}
`;
static properties = {
/**
* An array of strings representing the choices for the radio buttons.
* @type {Array<string>}
*/
choices: { type: Array },
/**
* The name for the radio button group. This is important for accessibility
* and ensuring only one radio button in the group can be selected.
* @type {string}
*/
name: { type: String },
/**
* The currently selected value.
* @type {string}
*/
value: { type: String, reflect: true },
/**
* The label or title for the radio group.
* @type {string}
*/
groupLabel: { type: String }
};
constructor() {
super();
this.choices = [];
this.name = 'radio-group'; // Default name
this.value = '';
this.groupLabel = '';
}
_handleChange(event) {
const selectedValue = event.target.value;
if (this.value !== selectedValue) {
this.value = selectedValue;
// Dispatch a custom event with the new value
this.dispatchEvent(new CustomEvent('radio-change', {
detail: { value: this.value, radioId: this.id },
bubbles: true, // Allows the event to bubble up through the DOM
composed: true // Allows the event to cross shadow DOM boundaries
}));
}
}
render() {
return html`
<div class="radio-group-container" role="radiogroup" aria-labelledby="group-label">
${this.groupLabel ? html`<span id="group-label" class="group-label">${this.groupLabel}</span>` : ''}
${this.choices.map(choice => html`
<label>
<input
type="radio"
name=${this.name}
.value=${choice}
.checked=${choice === this.value}
@change=${this._handleChange}
>
<span class="label-text">${choice}</span>
</label>
`)}
</div>
`;
}
}
customElements.define('radio-button-group', RadioButtonGroup);
// Generated by Gemini with some modifications
class ModalDialog extends LitElement {
static styles = css`
:host {
display: none; /* Hidden by default */
}
:host([open]) {
display: block; /* Show when open attribute is present */
}
.backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent black */
display: flex;
justify-content: center;
align-items: center;
z-index: 1000; /* Ensure it's on top */
}
.dialog {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
min-width: 300px; /* Or your desired width */
z-index: 1001; /* Above the backdrop */
display: flex;
flex-direction: column;
align-items: center;
margin: 0 15px;
}
`;
static get properties() {
return {
open: { type: Boolean, reflect: true },
};
}
constructor() {
super();
this.open = false;
}
_handleBackdropClick(event) {
// Close only if the click is directly on the backdrop, not on the dialog itself
if (event.target === this.shadowRoot.querySelector('.backdrop')) {
this.open = false;
this.dispatchEvent(new CustomEvent('dialog-closed', { bubbles: true, composed: true }));
}
}
_handleDialogClick(event) {
// Prevent clicks inside the dialog from bubbling up to the backdrop
event.stopPropagation();
}
render() {
if (!this.open) {
return html``;
}
return html`
<div class="backdrop" @click="${this._handleBackdropClick}">
<div class="dialog" @click="${this._handleDialogClick}">
<slot name="header"><h2>Default Header</h2></slot>
<slot>
<p>This is the default content of the modal.</p>
</slot>
<slot name="footer">
</slot>
</div>
</div>
`;
}
}
customElements.define('modal-dialog', ModalDialog);