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:
@@ -4,26 +4,54 @@ import sys
|
||||
import pprint
|
||||
import json
|
||||
|
||||
from utils import execute, ArgParseImpl, important_print
|
||||
from image_diff import same_image
|
||||
from utils import execute, ArgParseImpl, important_print, mkdir_p
|
||||
from image_diff import same_image, output_image_diff
|
||||
from results import RESULT_OK, RESULT_FAILED, RESULT_MISSING
|
||||
|
||||
def _compare_goldens(base_dir, comparison_dir):
|
||||
render_results = {}
|
||||
base_files = glob.glob(os.path.join(base_dir, "./**/*.tif"))
|
||||
for golden_file in base_files:
|
||||
base_fname = os.path.abspath(golden_file)
|
||||
test_case = base_fname.replace(f'{os.path.abspath(base_dir)}/', '')
|
||||
comp_fname = os.path.abspath(os.path.join(comparison_dir, test_case))
|
||||
if not os.path.exists(comp_fname):
|
||||
print(f'file name not found: {comp_fname}')
|
||||
render_results[test_case] = RESULT_MISSING
|
||||
continue
|
||||
if not same_image(base_fname, comp_fname):
|
||||
render_results[test_case] = RESULT_FAILED
|
||||
else:
|
||||
render_results[test_case] = RESULT_OK
|
||||
return render_results
|
||||
def _compare_goldens(base_dir, comparison_dir, out_dir=None):
|
||||
all_files = glob.glob(os.path.join(base_dir, "./**/*.tif"), recursive=True)
|
||||
test_dirs = set(os.path.abspath(os.path.dirname(f)).replace(os.path.abspath(base_dir) + '/', '') \
|
||||
for f in all_files)
|
||||
all_results = []
|
||||
for test_dir in test_dirs:
|
||||
results_meta = {}
|
||||
results = []
|
||||
output_test_dir = None if not out_dir else os.path.join(out_dir, test_dir)
|
||||
if output_test_dir:
|
||||
mkdir_p(output_test_dir)
|
||||
base_test_dir = os.path.abspath(os.path.join(base_dir, test_dir))
|
||||
comp_test_dir = os.path.abspath(os.path.join(comparison_dir, test_dir))
|
||||
results_meta['base_dir'] = base_test_dir
|
||||
results_meta['comparison_dir'] = comp_test_dir
|
||||
for golden_file in \
|
||||
glob.glob(os.path.join(base_test_dir, "*.tif")):
|
||||
base_fname = os.path.abspath(golden_file)
|
||||
test_case = base_fname.replace(f'{base_test_dir}/', '')
|
||||
comp_fname = os.path.join(comp_test_dir, test_case)
|
||||
result = {
|
||||
'name': test_case,
|
||||
}
|
||||
if not os.path.exists(comp_fname):
|
||||
print(f'file name not found: {comp_fname}')
|
||||
result['result'] = RESULT_MISSING
|
||||
elif not same_image(base_fname, comp_fname):
|
||||
result['result'] = RESULT_FAILED
|
||||
if output_test_dir:
|
||||
# just the file name
|
||||
diff_fname = f"{test_case.replace('.tif', '_diff.tif')}"
|
||||
output_image_diff(base_fname, comp_fname, os.path.join(output_test_dir, diff_fname))
|
||||
result['diff'] = diff_fname
|
||||
else:
|
||||
result['result'] = RESULT_OK
|
||||
results.append(result)
|
||||
if output_test_dir:
|
||||
results_meta['results'] = results
|
||||
output_fname = os.path.join(output_test_dir, "compare_results.json")
|
||||
with open(output_fname, 'w') as f:
|
||||
f.write(json.dumps(results_meta, indent=2))
|
||||
important_print(f'Written comparison results for {test_dir} to \n {output_fname}')
|
||||
all_results += results
|
||||
return all_results
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = ArgParseImpl()
|
||||
@@ -39,14 +67,9 @@ if __name__ == '__main__':
|
||||
dest = os.path.join(os.getcwd(), './out/renderdiff_tests')
|
||||
assert os.path.exists(dest), f"Destination folder={dest} does not exist."
|
||||
|
||||
results = _compare_goldens(args.src, dest)
|
||||
results = _compare_goldens(args.src, dest, out_dir=args.out)
|
||||
|
||||
if args.out:
|
||||
assert os.path.exists(arg.out), f"Output folder={dest} does not exist."
|
||||
with open(os.path.join(args.out, "compare_results.json", 'w')) as f:
|
||||
f.write(json.dumps(results))
|
||||
|
||||
failed = [f" {k}" for k in results.keys() if results[k] != RESULT_OK]
|
||||
failed = [f" {k['name']}" for k in results if k['result'] != RESULT_OK]
|
||||
success_count = len(results) - len(failed)
|
||||
important_print(f'Successfully compared {success_count} / {len(results)} images' +
|
||||
('\nFailed:\n' + ('\n'.join(failed)) if len(failed) > 0 else ''))
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
import tifffile
|
||||
import numpy
|
||||
import numpy as np
|
||||
|
||||
def same_image(tiff_file_a, tiff_file_b):
|
||||
try:
|
||||
@@ -27,7 +27,7 @@ def same_image(tiff_file_a, tiff_file_b):
|
||||
return False
|
||||
|
||||
# numpy.array_equal() checks if two arrays have the same shape and elements.
|
||||
if numpy.array_equal(img1_data, img2_data):
|
||||
if np.array_equal(img1_data, img2_data):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -41,3 +41,27 @@ def same_image(tiff_file_a, tiff_file_b):
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
return False
|
||||
|
||||
def output_image_diff(tiff_file_a, tiff_file_b, output_path):
|
||||
try:
|
||||
img1_data = tifffile.imread(tiff_file_a)
|
||||
img2_data = tifffile.imread(tiff_file_b)
|
||||
|
||||
# If the dimensions (height, width, number of channels, number of pages/frames)
|
||||
# are different, the images are not the same.
|
||||
if img1_data.shape != img2_data.shape:
|
||||
raise RuntimeError(f"Images {tiff_file_a} and {tiff_file_b} have different shapes: {img1_data.shape} vs {img2_data.shape}")
|
||||
|
||||
diff_img = np.abs(img1_data.astype(np.int16) - img2_data.astype(np.int16))
|
||||
diff_img = diff_img.astype(np.uint8)
|
||||
tifffile.imwrite(output_path, diff_img)
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Error: One or both files not found ('{file_path1}', '{file_path2}').")
|
||||
return False
|
||||
except tifffile.TiffFileError as e:
|
||||
print(f"Error: One or both files are not valid TIFF files or could not be read. Details: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
return False
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
|
||||
# Sets up the environment for scripts in test/renderdiff/
|
||||
|
||||
OUTPUT_DIR="$(pwd)/out/renderdiff_tests"
|
||||
RENDER_OUTPUT_DIR="$(pwd)/out/renderdiff/renders"
|
||||
DIFF_OUTPUT_DIR="$(pwd)/out/renderdiff/diffs"
|
||||
GOLDEN_OUTPUT_DIR="$(pwd)/out/renderdiff/goldens"
|
||||
RENDERDIFF_TEST_DIR="$(pwd)/test/renderdiff"
|
||||
MESA_DIR="$(pwd)/mesa/out/"
|
||||
VENV_DIR="$(pwd)/venv"
|
||||
@@ -33,6 +35,7 @@ else
|
||||
fi
|
||||
|
||||
function start_() {
|
||||
mkdir -p ${RENDER_OUTPUT_DIR} ${DIFF_OUTPUT_DIR} ${GOLDEN_OUTPUT_DIR}
|
||||
if [[ "$GITHUB_WORKFLOW" ]]; then
|
||||
set -ex
|
||||
fi
|
||||
|
||||
@@ -102,7 +102,7 @@ if __name__ == "__main__":
|
||||
vk_icd=args.vk_icd)
|
||||
|
||||
with open(f'{output_dir}/render_results.json', 'w') as f:
|
||||
f.write(json.dumps(results))
|
||||
f.write(json.dumps(results, indent=2))
|
||||
|
||||
shutil.copy2(args.test, f'{output_dir}/test.json')
|
||||
|
||||
|
||||
81
test/renderdiff/src/viewer.py
Normal file
81
test/renderdiff/src/viewer.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# 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 os
|
||||
import sys
|
||||
import flask
|
||||
import pathlib
|
||||
import json
|
||||
|
||||
from utils import ArgParseImpl
|
||||
|
||||
from flask import Flask, request, make_response, send_from_directory
|
||||
|
||||
DIR = pathlib.Path(__file__).parent.absolute()
|
||||
HTML_DIR = os.path.join(DIR, "viewer_html")
|
||||
|
||||
def _create_app(config):
|
||||
app = Flask(__name__)
|
||||
|
||||
client_config = config.copy()
|
||||
base_dir = client_config['base_dir']
|
||||
comparison_dir = client_config['comparison_dir']
|
||||
diff_dir = client_config['diff_dir']
|
||||
|
||||
del client_config['base_dir']
|
||||
del client_config['comparison_dir']
|
||||
del client_config['diff_dir']
|
||||
|
||||
@app.route('/r/', methods=['GET'])
|
||||
def get_r():
|
||||
return json.dumps(client_config)
|
||||
|
||||
@app.route('/g/<path:filepath>', methods=['GET'])
|
||||
def get_g(filepath):
|
||||
return send_from_directory(base_dir, filepath)
|
||||
|
||||
@app.route('/d/<path:filepath>', methods=['GET'])
|
||||
def get_d(filepath):
|
||||
return send_from_directory(diff_dir, filepath)
|
||||
|
||||
@app.route('/c/<path:filepath>', methods=['GET'])
|
||||
def get_c(filepath):
|
||||
return send_from_directory(comparison_dir, filepath)
|
||||
|
||||
@app.route('/<path:filepath>')
|
||||
def get_static_file(filepath):
|
||||
return send_from_directory(HTML_DIR, filepath)
|
||||
|
||||
@app.route('/')
|
||||
def get_index():
|
||||
return send_from_directory(HTML_DIR, 'index.html')
|
||||
|
||||
app.url_map.strict_slashes = False
|
||||
return app
|
||||
|
||||
if __name__ == '__main__':
|
||||
PORT = 8901
|
||||
parser = ArgParseImpl()
|
||||
parser.add_argument('--diff', help='Diff directory', required=True)
|
||||
args, _ = parser.parse_known_args(sys.argv[1:])
|
||||
|
||||
with open(os.path.join(args.diff, 'compare_results.json'), 'r') as f:
|
||||
config = json.loads(f.read())
|
||||
config['diff_dir'] = os.path.abspath(args.diff)
|
||||
|
||||
app = _create_app(config)
|
||||
from waitress import serve
|
||||
|
||||
print(f'Point your browser to http://localhost:{PORT} to see the diff results')
|
||||
serve(app, host="127.0.0.1", port=PORT)
|
||||
299
test/renderdiff/src/viewer_html/app.js
Normal file
299
test/renderdiff/src/viewer_html/app.js
Normal 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);
|
||||
36
test/renderdiff/src/viewer_html/index.html
Normal file
36
test/renderdiff/src/viewer_html/index.html
Normal file
@@ -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>
|
||||
96
test/renderdiff/src/viewer_html/tiff-viewer.js
Normal file
96
test/renderdiff/src/viewer_html/tiff-viewer.js
Normal file
@@ -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
test/renderdiff/src/viewer_html/tools.js
Normal file
225
test/renderdiff/src/viewer_html/tools.js
Normal 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);
|
||||
Reference in New Issue
Block a user