Compare commits

...

1 Commits

Author SHA1 Message Date
Powei Feng
d869223c82 render-val: add page generation for results 2026-04-28 23:07:30 -07:00
11 changed files with 1978 additions and 99 deletions

View File

@@ -57,29 +57,47 @@ Depending on the Android version and device storage policy, the app's file locat
--- ---
## Python Terminal UI (TUI) Dashboard ## Render Validation Results Viewer
To make managing tests and results easier, a Python-based Textual TUI (`validation_app.py`) is provided in the `validation_tui` directory. It automatically polls the connected device using ADB, acts as a GUI for the intent commands above, and handles downloading/uploading `.zip` bundles to circumvent Android's scoped storage limits. The project includes a static web viewer to visualize and compare test results across different devices. The viewer supports high-resolution image comparison with zoom/pan controls and dynamic diffing.
### Setup ### Setup & Requirements
1. Ensure you have Python 3 and `adb` installed and in your PATH. The results processor requires `numpy` and `Pillow`. These are not included in the main `requirements.txt` to keep the TUI dependencies minimal.
2. Navigate to the TUI directory: `cd test/render-validation`
3. Create a virtual environment: `python3 -m venv venv` 1. Install processing dependencies:
4. Activate it: `source venv/bin/activate` (Mac/Linux) or `venv\Scripts\activate` (Windows) ```bash
5. Install requirements: `pip install -r requirements.txt` (Installs the `textual` framework) pip install numpy Pillow
```
### 1. Process Result Bundles
The `process_results.py` script takes a directory of `.zip` result files (exported from the Android app) and generates a static web folder.
### Usage
Start the dashboard by running:
```bash ```bash
python validation_app.py # Usage: python process_results.py <input_zip_dir> <output_web_dir>
python process_results.py ./my_results ./web_output
``` ```
### TUI Features This script:
- **Auto-Polling Mechanism**: Syncs the file lists with your device every 2 seconds. - Extracts images and metadata from the result zips.
- **Generate Test/Result Buttons**: One-click execution of the `am start` intents. - Generates thumbnails for efficient browser performance.
- **Upload Local Test Bundle**: Automatically pushes a local `.zip` file from your PC to the correct directory on the Android device. - Packages the exact tolerance configurations for the web viewer.
- **Per-File Actions**:
- `▶` (Load): Restarts the app with `--es zip_path <filename>` to set it as the active test on device. ### 2. View Results
- `↓` (Download): Pulls the `.zip` to your PC's current working directory. Because the viewer uses ES modules and fetches data, it must be served via a web server.
- `✎` (Rename): Quickly renames the file directly on the Android file system.
- `✗` (Delete): Quickly removes the file from the Android device to free up storage. ```bash
cd ./web_output
python3 -m http.server 1234
```
Navigate to `http://localhost:1234` in your desktop browser.
### Web Viewer Features
- **Tabular Overview**: Compare results across multiple devices and test runs in a single grid.
- **High-Res Viewer**: Click any thumbnail to open a full-size modal.
- **Zoom & Pan**: Use the mouse wheel to zoom and left-click-drag to pan around the render.
- **Comparison Modes**: Cycle between "Rendered", "Golden", and "Diff" views.
- **Dynamic JS Diffing**: The `imagediff` algorithm (including `shiftRadius`, `blurRadius`, and complex tolerance trees) is implemented in JavaScript and computed on-the-fly.
- **Fail Highlighting**: Toggle "Highlight Failing Pixels" in the Diff view to see exactly which pixels exceeded the tolerance threshold in pure red.
- **Contrast Control**: Use the contrast slider to amplify subtle rendering differences.

View File

@@ -0,0 +1,161 @@
import argparse
import json
import os
import shutil
import zipfile
from pathlib import Path
from PIL import Image
def get_test_tolerance(config, full_test_name):
# test name is e.g. "basic.opengl.DamagedHelmet"
parts = full_test_name.split('.')
if not parts: return None
test_id = parts[0]
for test in config.get("tests", []):
if test.get("name") == test_id:
return test.get("tolerance")
return None
def process_zip(zip_path, output_dir, device_name):
"""Extract and process a single zip file from the results folder."""
extract_dir = os.path.join(output_dir, "tmp", device_name)
os.makedirs(extract_dir, exist_ok=True)
with zipfile.ZipFile(zip_path, 'r') as z:
z.extractall(extract_dir)
results_json_path = os.path.join(extract_dir, "results.json")
if not os.path.exists(results_json_path):
return {}, []
with open(results_json_path, 'r') as f:
try:
device_results = json.load(f)
except json.JSONDecodeError:
return {}, []
metadata = device_results['metadata']
bundle_zip_path = os.path.join(extract_dir, "bundle.zip")
bundle_dir = os.path.join(extract_dir, "bundle")
if os.path.exists(bundle_zip_path):
os.makedirs(bundle_dir, exist_ok=True)
with zipfile.ZipFile(bundle_zip_path, 'r') as z:
z.extractall(bundle_dir)
config_path = os.path.join(bundle_dir, "default_test", "config.json")
config = {}
if os.path.exists(config_path):
with open(config_path, 'r') as f:
config = json.load(f)
run_results = []
for test_result in device_results.get("results", []):
test_name = test_result.get("test_name")
passed = test_result.get("passed", False)
rendered_path = os.path.join(extract_dir, f"{test_name}.png")
golden_path = os.path.join(bundle_dir, "default_test", "goldens", f"{test_name}.png")
if not os.path.exists(rendered_path):
continue
rendered_img = Image.open(rendered_path).convert("RGBA")
# Output paths for web
rel_test_dir = f"{device_name}/{test_name}"
out_test_dir = os.path.join(output_dir, "assets", rel_test_dir)
os.makedirs(out_test_dir, exist_ok=True)
out_golden = os.path.join(out_test_dir, "golden.png")
out_rendered = os.path.join(out_test_dir, "rendered.png")
out_thumb = os.path.join(out_test_dir, "thumb.png")
rendered_img.save(out_rendered)
# Generate thumbnail
thumb_size = (128, 128)
thumb_img = rendered_img.copy()
thumb_img.thumbnail(thumb_size)
thumb_img.save(out_thumb)
has_golden = False
if os.path.exists(golden_path):
has_golden = True
shutil.copy2(golden_path, out_golden)
else:
Image.new("RGBA", rendered_img.size, (0,0,0,0)).save(out_golden)
tolerance_config = get_test_tolerance(config, test_name)
run_results.append({
"testName": test_name,
"passed": passed,
"golden": f"assets/{rel_test_dir}/golden.png",
"rendered": f"assets/{rel_test_dir}/rendered.png",
"thumb": f"assets/{rel_test_dir}/thumb.png",
"hasGolden": has_golden,
"config": tolerance_config
})
return [metadata, run_results]
def main():
parser = argparse.ArgumentParser(description="Process render validation zip results.")
parser.add_argument("input_dir", help="Directory containing .zip result files")
parser.add_argument("output_dir", help="Directory to output the static web viewer")
args = parser.parse_args()
input_dir = Path(args.input_dir)
output_dir = Path(args.output_dir)
if not input_dir.exists():
print(f"Error: Input directory {input_dir} does not exist.")
return
os.makedirs(output_dir, exist_ok=True)
os.makedirs(output_dir / "assets", exist_ok=True)
all_results = []
for zip_file in input_dir.glob("*.zip"):
device_name = zip_file.stem
print(f"Processing {zip_file.name} for device {device_name}...")
metadata, device_results = process_zip(zip_file, output_dir, device_name)
if device_results:
all_results.append({
"metadata": metadata,
"device": device_name,
"runs": device_results
})
# Cleanup tmp
tmp_dir = output_dir / "tmp"
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
# Write data.json
with open(output_dir / "data.json", "w") as f:
json.dump(all_results, f, indent=2)
# Copy web viewer files
viewer_src = Path(__file__).parent / "result-viewer"
if viewer_src.exists():
for item in viewer_src.iterdir():
if item.is_file():
shutil.copy2(item, output_dir)
elif item.is_dir():
dest_dir = output_dir / item.name
if dest_dir.exists():
shutil.rmtree(dest_dir)
shutil.copytree(item, dest_dir)
print("Done. To view results, run a static server in the output directory:")
print(f"cd {output_dir} && python3 -m http.server 1234")
if __name__ == "__main__":
main()

View File

@@ -1 +1,3 @@
textual>=0.52.0 textual>=0.52.0
Pillow
numpy

View File

@@ -0,0 +1,35 @@
import './components/result-table.js';
import './components/image-viewer.js';
async function init() {
const container = document.getElementById('container');
try {
const response = await fetch('./data.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
container.innerHTML = `
<result-table></result-table>
<image-viewer></image-viewer>
`;
const resultTable = container.querySelector('result-table');
const imageViewer = container.querySelector('image-viewer');
resultTable.results = data;
// Listen for view events from the table
container.addEventListener('view-result', (e) => {
imageViewer.open(e.detail.device, e.detail.run);
});
} catch (e) {
container.innerHTML = `<div style="color: red; padding: 20px;">Error loading results: ${e.message}.<br>Make sure you are running a local server.</div>`;
console.error("Failed to load data:", e);
}
}
document.addEventListener('DOMContentLoaded', init);

View File

@@ -0,0 +1,699 @@
import { html, css, LitElement } from 'lit';
import './tiff-viewer.js';
const RES_EQUAL = "equal";
const RES_MISMATCHED_DIMENSIONS = "mismatched dimensions";
const RES_DIFFERENT_PIXELS = "different pixels";
const RES_NOT_READY = "not ready";
export class ImageViewer extends LitElement {
static properties = {
runData: { type: Object },
deviceName: { type: String },
isOpen: { type: Boolean },
diffResult: { type: Object },
magnifierEnabled: { type: Boolean },
highlightFailing: { type: Boolean },
currentDiffImageData: { type: Object },
viewMode: { type: String },
toggleState: { type: String },
autoAlternate: { type: Boolean }
};
static styles = css`
:host {
display: block;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(255, 255, 255, 0.95);
z-index: 1000;
display: flex;
flex-direction: column;
color: black;
overflow-y: auto;
}
.header {
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 10;
position: sticky;
top: 0;
}
.title {
font-size: 1.2em;
font-weight: bold;
}
.controls {
display: flex;
gap: 15px;
align-items: center;
}
.btn {
background: #fff;
color: #333;
border: 1px solid #ccc;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background 0.2s;
}
.btn:hover { background: #eee; }
.close-btn {
background: #e74c3c;
border-color: #c0392b;
color: white;
}
.close-btn:hover { background: #c0392b; color: white; }
.main-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.viewer-container {
display: flex;
flex-direction: row;
position: relative;
width: 100%;
justify-content: center;
gap: 10px;
}
.control-panel {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.viewer-wrap {
flex: 1;
max-width: 33%;
display: flex;
flex-direction: column;
align-items: center;
}
.viewer-wrap.full-width {
max-width: 512px;
}
.viewer-label {
font-weight: bold;
margin-bottom: 10px;
font-size: 1.1em;
}
tiff-viewer {
width: 100%;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
}
`;
constructor() {
super();
this.isOpen = false;
this.magnifierEnabled = true;
this.highlightFailing = false;
this.originalDiffImageData = null;
this.currentDiffImageData = null;
this.diffResult = null;
this.leftImageLoaded = false;
this.rightImageLoaded = false;
this.viewMode = 'side-by-side';
this.toggleState = 'rendered';
this.autoAlternate = false;
this._alternateInterval = null;
this.addEventListener(
'image-loaded',
(ev) => {
if (!this.runData) return;
if (ev.detail.url === this.runData.golden) {
this.leftImageLoaded = true;
}
if (ev.detail.url === this.runData.rendered) {
this.rightImageLoaded = true;
}
if (this.leftImageLoaded && this.rightImageLoaded) {
this._triggerDiff();
}
}
);
}
open(deviceName, runData) {
this.deviceName = deviceName;
this.runData = runData;
this.isOpen = true;
this.diffResult = null;
this.currentDiffImageData = null;
this.originalDiffImageData = null;
this.leftImageLoaded = false;
this.rightImageLoaded = false;
this.highlightFailing = false;
this.viewMode = 'side-by-side';
this.toggleState = 'rendered';
this.autoAlternate = false;
if (this._alternateInterval) {
clearInterval(this._alternateInterval);
this._alternateInterval = null;
}
document.body.style.overflow = 'hidden';
}
close() {
this.isOpen = false;
document.body.style.overflow = '';
if (this._alternateInterval) {
clearInterval(this._alternateInterval);
this._alternateInterval = null;
}
}
_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 imgLeft = tiffViewerLeft.imgdata;
const imgRight = tiffViewerRight.imgdata;
if (!imgLeft || !imgRight) {
return { "result": RES_NOT_READY };
}
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 width = imgLeft.width;
const height = imgLeft.height;
const goldenData = imgLeft.data;
const renderedData = imgRight.data;
const imgDiff = new Uint8ClampedArray(width * height * 4);
const maxDiff = [0, 0, 0, 0];
const config = this.runData?.config;
const highlight = this.highlightFailing;
const blurCacheG = new Map();
const blurCacheR = new Map();
if (highlight && config) {
blurCacheG.set(0, goldenData);
blurCacheR.set(0, renderedData);
const applyBlur = (data, radius) => {
if (radius === 0) return data;
const out = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r=0, g=0, b=0, a=0, count=0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
let nx = Math.max(0, Math.min(width - 1, x + dx));
let ny = Math.max(0, Math.min(height - 1, y + dy));
let idx = (ny * width + nx) * 4;
r += data[idx];
g += data[idx+1];
b += data[idx+2];
a += data[idx+3];
count++;
}
}
let oidx = (y * width + x) * 4;
out[oidx] = r/count;
out[oidx+1] = g/count;
out[oidx+2] = b/count;
out[oidx+3] = 255;
}
}
return out;
};
const getUniqueBlurs = (cfg, out = new Set()) => {
if (!cfg) return out;
if (cfg.mode === "LEAF" || !cfg.mode) {
out.add(cfg.blurRadius || 0);
} else if (cfg.children) {
for (let c of cfg.children) getUniqueBlurs(c, out);
}
return out;
};
const blurs = getUniqueBlurs(config);
for (let rad of blurs) {
if (rad > 0 && !blurCacheG.has(rad)) {
blurCacheG.set(rad, applyBlur(goldenData, rad));
blurCacheR.set(rad, applyBlur(renderedData, rad));
}
}
}
const checkPixel = (x, y, cfg) => {
const mode = cfg.mode || "LEAF";
if (mode === "LEAF") {
const shiftRad = cfg.shiftRadius || 0;
const blurRad = cfg.blurRadius || 0;
const maxAllowedDiff = (cfg.maxAbsDiff || 0.0) * 255.0;
const channelMask = cfg.channelMask !== undefined ? cfg.channelMask : 15;
const gData = blurCacheG.get(blurRad);
const rData = blurCacheR.get(blurRad);
const activeCh = [];
if (channelMask & 1) activeCh.push(0);
if (channelMask & 2) activeCh.push(1);
if (channelMask & 4) activeCh.push(2);
if (channelMask & 8) activeCh.push(3);
let rIdx = (y * width + x) * 4;
let rc = [rData[rIdx], rData[rIdx+1], rData[rIdx+2], rData[rIdx+3]];
for (let dy = -shiftRad; dy <= shiftRad; dy++) {
for (let dx = -shiftRad; dx <= shiftRad; dx++) {
let nx = Math.max(0, Math.min(width - 1, x + dx));
let ny = Math.max(0, Math.min(height - 1, y + dy));
let idx = (ny * width + nx) * 4;
let match = true;
for (let c of activeCh) {
if (Math.abs(gData[idx+c] - rc[c]) > maxAllowedDiff) {
match = false;
break;
}
}
if (match) return true;
}
}
return false;
} else if (mode === "AND") {
for (let child of (cfg.children || [])) {
if (!checkPixel(x, y, child)) return false;
}
return true;
} else if (mode === "OR") {
for (let child of (cfg.children || [])) {
if (checkPixel(x, y, child)) return true;
}
return false;
}
return false;
};
for (let i = 0; i < width * height; i++) {
const idx = i * 4;
const x = i % width;
const y = Math.floor(i / width);
let rDiff = Math.abs(goldenData[idx] - renderedData[idx]);
let gDiff = Math.abs(goldenData[idx+1] - renderedData[idx+1]);
let bDiff = Math.abs(goldenData[idx+2] - renderedData[idx+2]);
let aDiff = Math.abs(goldenData[idx+3] - renderedData[idx+3]);
maxDiff[0] = Math.max(maxDiff[0], rDiff);
maxDiff[1] = Math.max(maxDiff[1], gDiff);
maxDiff[2] = Math.max(maxDiff[2], bDiff);
maxDiff[3] = Math.max(maxDiff[3], aDiff);
if (highlight) {
let pass = true;
if (config) {
pass = checkPixel(x, y, config);
} else {
pass = (rDiff === 0 && gDiff === 0 && bDiff === 0 && aDiff === 0);
}
if (!pass) {
imgDiff[idx] = 255;
imgDiff[idx+1] = 0;
imgDiff[idx+2] = 0;
imgDiff[idx+3] = 255;
} else {
imgDiff[idx] = rDiff;
imgDiff[idx+1] = gDiff;
imgDiff[idx+2] = bDiff;
imgDiff[idx+3] = 255;
}
} else {
imgDiff[idx] = rDiff;
imgDiff[idx+1] = gDiff;
imgDiff[idx+2] = bDiff;
imgDiff[idx+3] = 255;
}
}
if (maxDiff[0] == 0 && maxDiff[1] == 0 && maxDiff[2] == 0 && maxDiff[3] == 0) {
return {
"result": RES_EQUAL,
"explanation": "Equal",
"dim": {"width": width, "height": height },
}
}
return {
"result": RES_DIFFERENT_PIXELS,
"explanation": "Images are different",
"dim": {"width": width, "height": height },
"maxDiff": maxDiff,
"diffImg": imgDiff,
};
}
_triggerDiff() {
const diff = this._computeDiff();
if (diff.result == RES_DIFFERENT_PIXELS) {
this.diffResult = diff;
this.originalDiffImageData = null;
const multDiv = this.shadowRoot.querySelector('#diffMultiplier');
if (multDiv) {
this._updateDiffCanvas(this.diffResult, multDiv.value);
} else {
this._updateDiffCanvas(this.diffResult, 1);
}
} else {
this.diffResult = null;
this.currentDiffImageData = null;
}
}
_updateDiffCanvas(diffResult, mult) {
const diffImgCopy = diffResult.diffImg.slice();
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;
}
const imgData = new ImageData(diffImgCopy, diffResult.dim.width, diffResult.dim.height);
if (!this.originalDiffImageData) {
this.originalDiffImageData = new ImageData(diffResult.diffImg.slice(), diffResult.dim.width, diffResult.dim.height);
}
this.currentDiffImageData = imgData;
}
_onGlobalMouseLeave(event) {
for (const name of ['left', 'right', 'diff']) {
const viewer = this.shadowRoot.querySelector('#viewer-' + name);
if (viewer) {
const mag = viewer.shadowRoot?.getElementById('magnifier');
if (mag) {
mag.hide();
}
}
}
}
_onGlobalMouseMove(event) {
if (!this.magnifierEnabled) return;
for (const name of ['left', 'right', 'diff']) {
const viewer = this.shadowRoot.querySelector('#viewer-' + name);
if (!viewer) continue;
const canvas = viewer.shadowRoot?.querySelector('canvas');
if (!canvas) continue;
const imageData = viewer.imgdata;
if (!imageData) continue;
const magnifier = viewer.shadowRoot?.getElementById('magnifier');
if (!this._isMouseOverAnyView(event)) {
magnifier.hide();
continue;
}
const rect = canvas.getBoundingClientRect();
const { imageX, imageY, mouseX, mouseY } = this._calculateEquivalentPosition(event, rect, imageData);
this._lastImageX = imageX;
this._lastImageY = imageY;
const origData = name == 'diff' ? this.originalDiffImageData : null;
viewer.updateMagnifier(imageX, imageY, origData);
}
}
_isMouseOverElement(event, rect) {
return event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
}
_isMouseOverAnyView(event) {
const checkView = (id) => {
const viewer = this.shadowRoot.querySelector(id);
if (viewer) {
const canvas = viewer.shadowRoot?.querySelector('canvas');
if (canvas && this._isMouseOverElement(event, canvas.getBoundingClientRect())) return true;
}
return false;
}
return checkView('#viewer-left') || checkView('#viewer-right') || checkView('#viewer-diff');
}
_calculateEquivalentPosition(event, targetRect, targetImageData) {
let sourceRect = null;
let sourceImageData = null;
const getSource = (id) => {
const viewer = this.shadowRoot.querySelector(id);
if (viewer) {
const canvas = viewer.shadowRoot?.querySelector('canvas');
if (canvas && this._isMouseOverElement(event, canvas.getBoundingClientRect())) {
return { rect: canvas.getBoundingClientRect(), img: viewer.imgdata };
}
}
return null;
};
let src = getSource('#viewer-left') || getSource('#viewer-right') || (this.diffResult ? getSource('#viewer-diff') : null);
if (src) {
sourceRect = src.rect;
sourceImageData = src.img;
}
if (!sourceRect || !sourceImageData) {
sourceRect = targetRect;
sourceImageData = targetImageData;
}
const sourceMouseX = event.clientX - sourceRect.left;
const sourceMouseY = event.clientY - sourceRect.top;
const sourceScaleX = sourceImageData.width / sourceRect.width;
const sourceScaleY = sourceImageData.height / sourceRect.height;
const sourceImageX = Math.floor(sourceMouseX * sourceScaleX);
const sourceImageY = Math.floor(sourceMouseY * sourceScaleY);
const targetScaleX = targetImageData.width / targetRect.width;
const targetScaleY = targetImageData.height / targetRect.height;
const targetMouseX = sourceImageX / targetScaleX;
const targetMouseY = sourceImageY / targetScaleY;
return {
imageX: sourceImageX,
imageY: sourceImageY,
mouseX: targetMouseX,
mouseY: targetMouseY
};
}
_updateMagnifiersForToggle() {
if (!this.magnifierEnabled || this._lastImageX === undefined) return;
const activeViewerId = this.toggleState === 'rendered' ? '#viewer-right' : '#viewer-left';
const viewer = this.shadowRoot.querySelector(activeViewerId);
if (!viewer || !viewer.imgdata) return;
const origData = null; // No origData needed for golden/rendered
viewer.updateMagnifier(this._lastImageX, this._lastImageY, origData);
}
render() {
if (!this.isOpen || !this.runData) return html``;
const showDiff = !!this.diffResult;
const onMultiplierChange = (ev) => {
const multiplierValue = this.shadowRoot.querySelector('#multiplierValue');
multiplierValue.textContent = ev.target.value;
this._updateDiffCanvas(this.diffResult, ev.target.value);
};
const onMagnifierToggle = (ev) => {
this.magnifierEnabled = ev.target.checked;
};
const onHighlightToggle = (ev) => {
this.highlightFailing = ev.target.checked;
this._triggerDiff();
};
const toggleViewMode = async () => {
this.viewMode = this.viewMode === 'side-by-side' ? 'toggle' : 'side-by-side';
if (this.viewMode === 'toggle') {
this.autoAlternate = true;
if (!this._alternateInterval) {
this._alternateInterval = setInterval(async () => {
this.toggleState = this.toggleState === 'rendered' ? 'golden' : 'rendered';
await this.updateComplete;
this._updateMagnifiersForToggle();
}, 2000);
}
} else {
this.autoAlternate = false;
if (this._alternateInterval) {
clearInterval(this._alternateInterval);
this._alternateInterval = null;
}
}
await this.updateComplete;
if (this.viewMode === 'toggle') {
this._updateMagnifiersForToggle();
}
};
const switchToggleState = async () => {
if (this.autoAlternate) return;
this.toggleState = this.toggleState === 'rendered' ? 'golden' : 'rendered';
await this.updateComplete;
this._updateMagnifiersForToggle();
};
const onAutoAlternateChange = (ev) => {
this.autoAlternate = ev.target.checked;
if (this.autoAlternate) {
this._alternateInterval = setInterval(async () => {
this.toggleState = this.toggleState === 'rendered' ? 'golden' : 'rendered';
await this.updateComplete;
this._updateMagnifiersForToggle();
}, 2000);
} else {
if (this._alternateInterval) {
clearInterval(this._alternateInterval);
this._alternateInterval = null;
}
}
};
return html`
<div class="modal-overlay">
<div class="header">
<div class="title">${this.deviceName} - ${this.runData.testName}</div>
<div class="controls">
<button class="btn" @click="${toggleViewMode}">
${this.viewMode === 'side-by-side' ? 'Switch to Toggle Mode' : 'Switch to Side-by-Side'}
</button>
<button class="btn close-btn" @click="${this.close}">Close</button>
</div>
</div>
<div class="main-container">
<div class="viewer-container ${this.viewMode === 'toggle' ? 'toggle-mode' : ''}" @mousemove="${this._onGlobalMouseMove}" @mouseleave="${this._onGlobalMouseLeave}">
<div class="viewer-wrap ${this.viewMode === 'toggle' ? 'full-width' : ''}"
style="${this.viewMode === 'toggle' && this.toggleState !== 'golden' ? 'display: none;' : ''}">
<div class="viewer-label">Golden</div>
<tiff-viewer id="viewer-left" class="viewer"
fileurl="${this.runData.golden}"
?magnifier-enabled="${this.magnifierEnabled}"
disable-mouse-handlers></tiff-viewer>
</div>
<div class="viewer-wrap" style="${(!showDiff || this.viewMode === 'toggle') ? 'display: none;' : ''}">
<div class="viewer-label">Diff</div>
<tiff-viewer id="viewer-diff" class="viewer"
name="diff"
.srcdata="${this.currentDiffImageData}"
?magnifier-enabled="${this.magnifierEnabled}"
disable-mouse-handlers></tiff-viewer>
</div>
<div class="viewer-wrap ${this.viewMode === 'toggle' ? 'full-width' : ''}"
style="${this.viewMode === 'toggle' && this.toggleState !== 'rendered' ? 'display: none;' : ''}">
<div class="viewer-label">Rendered</div>
<tiff-viewer id="viewer-right" class="viewer"
fileurl="${this.runData.rendered}"
?magnifier-enabled="${this.magnifierEnabled}"
disable-mouse-handlers></tiff-viewer>
</div>
</div>
<div class="control-panel" style="${(this.viewMode === 'toggle' || !showDiff) ? 'display: none;' : ''}">
<div>
<strong>Difference Multiplier:</strong> <span id="multiplierValue">1</span>x
</div>
<input type="range" min="1" max="100" value="1" id="diffMultiplier" @input=${onMultiplierChange} style="width: 200px;">
<div style="display: flex; gap: 20px; margin-top: 10px;">
<label style="cursor: pointer; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" .checked="${this.magnifierEnabled}" @change=${onMagnifierToggle}>
Enable Magnifier
</label>
<label style="cursor: pointer; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" .checked="${this.highlightFailing}" @change=${onHighlightToggle}>
Highlight Failing Pixels (Red)
</label>
</div>
</div>
<div class="control-panel" style="${this.viewMode === 'side-by-side' ? 'display: none;' : ''}">
<div style="display: flex; gap: 20px; align-items: center;">
<button class="btn" @click="${switchToggleState}" ?disabled="${this.autoAlternate}">
Switch to ${this.toggleState === 'rendered' ? 'Golden' : 'Rendered'}
</button>
<label style="cursor: pointer; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" .checked="${this.autoAlternate}" @change=${onAutoAlternateChange}>
Auto-Alternate (2s)
</label>
<label style="cursor: pointer; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" .checked="${this.magnifierEnabled}" @change=${onMagnifierToggle}>
Enable Magnifier
</label>
</div>
</div>
</div>
</div>
`;
}
}
customElements.define('image-viewer', ImageViewer);

View File

@@ -0,0 +1,333 @@
import { html, css, LitElement } from 'lit';
import './test-filter.js';
export class ResultTable extends LitElement {
static properties = {
results: { type: Array },
sortBy: { type: String },
backendFilter: { type: String },
nameFilters: { type: Array },
showOnlyFailures: { type: Boolean },
gpuFilters: { type: Array }
};
constructor() {
super();
this.sortBy = 'name-gpu-os';
this.backendFilter = 'all';
this.nameFilters = [];
this.showOnlyFailures = false;
this.gpuFilters = [];
}
static styles = css`
:host {
display: block;
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
overflow: auto;
max-height: 100vh;
max-width: 100vw;
font-size: 11px;
}
.filter-bar {
padding: 5px 10px;
background: #f8f9fa;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
gap: 10px;
position: sticky;
top: 0;
left: 0;
z-index: 20;
margin-left: 200px;
margin-right: -200px;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
th, td {
padding: 4px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #2c3e50;
white-space: nowrap;
position: sticky;
top: 35px; /* Offset by filter-bar height approx */
z-index: 10;
}
.device-col {
color: #34495e;
border-right: 1px solid #eee;
position: sticky;
left: 0;
background-color: #fff;
z-index: 5;
width: 200px;
min-width: 200px;
max-width: 200px;
box-sizing: border-box;
}
th.device-col {
z-index: 15;
background-color: #f8f9fa;
}
.result-cell {
text-align: center;
}
.result-container {
display: flex;
padding: 6px;
border-radius: 6px;
cursor: pointer;
transition: transform 0.2s;
width: 50px;
height: 50px;
}
.result-container:hover {
transform: scale(1.05);
}
.pass {
background-color: #d4edda;
border: 1px solid #c3e6cb;
}
.fail {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
}
.thumb {
display: block;
width: 100%;
}
.test-name {
font-size: 0.85em;
color: #555;
}
.test-name-container {
vertical-align: top;
}
`;
render() {
if (!this.results || this.results.length === 0) {
return html`<p style="padding: 20px;">No results found.</p>`;
}
// Extract all unique test names to build columns
const allTests = new Set();
this.results.forEach(device => {
device.runs.forEach(run => allTests.add(run.testName));
});
let testNames = Array.from(allTests).sort(
(a, b) => {
let [atest, abackend, amodel] = a.split('.');
let [btest, bbackend, bmodel] = b.split('.');
if (abackend === bbackend) {
return atest < btest ? -1 : 1;
}
return abackend < bbackend ? -1 : 1;
}
);
// Apply backend filter
if (this.backendFilter !== 'all') {
testNames = testNames.filter(name => {
const [, backend] = name.split('.');
return backend === this.backendFilter;
});
}
// Apply name filters (OR logic)
if (this.nameFilters && this.nameFilters.length > 0) {
testNames = testNames.filter(name => {
return this.nameFilters.some(filterStr => name.includes(filterStr));
});
}
// Apply failure filter
if (this.showOnlyFailures) {
testNames = testNames.filter(name => {
return this.results.some(device => {
const run = device.runs.find(r => r.testName === name);
return run && !run.passed;
});
});
}
const nameToDiv = (name) => {
const [testName, backend, modelName] = name.split('.');
const sname = testName.split('_').map((n,i) => {
const style = (i > 0) ? 'font-size:8px' : '';
return html`<span style="${style}">${n}</span>`
});
const border = "border:1px solid black;border-radius:5px;padding:3px;";
const buttonStyle = border + "font-size:9px;";
const buttonColor = backend == 'opengl' ?
"background-color:#e0e3c0" :
"background-color:#b3b0f0";
return html`
<th class="test-name-container">
<div style="margin-bottom:5px;display:inline-flex;flex-direction:column;${border}">
${sname}
</div>
<span style="display:flex">
<div style="${buttonStyle};${buttonColor}">
${backend}
</div>
</span>
</th>
`;
};
const testRow = testNames.map(nameToDiv);
const getShortGPUName = (device) => {
const gpuStr = device.metadata?.gpu_driver_info?.opengl || '';
if (gpuStr.includes('PowerVR')) return 'PowerVR';
if (gpuStr.includes('Mali')) return 'Mali';
if (gpuStr.includes('Adreno')) return 'Adreno';
if (gpuStr.includes('Xclipse')) return 'Xclipse';
return gpuStr;
};
let filteredDevices = this.results;
if (this.gpuFilters && this.gpuFilters.length > 0) {
filteredDevices = filteredDevices.filter(device => {
const gpuStr = device.metadata?.gpu_driver_info?.opengl || '';
return this.gpuFilters.some(filterStr => gpuStr.toLowerCase().includes(filterStr.toLowerCase()));
});
}
const sortedResults = [...filteredDevices].sort((a, b) => {
const nameA = a.metadata.device_name || '';
const nameB = b.metadata.device_name || '';
const gpuA = getShortGPUName(a);
const gpuB = getShortGPUName(b);
const osA = parseInt(a.metadata.android_version, 10) || 0;
const osB = parseInt(b.metadata.android_version, 10) || 0;
const cmpName = nameA.localeCompare(nameB);
const cmpGpu = gpuA.localeCompare(gpuB);
const cmpOs = osA - osB;
if (this.sortBy === 'name-gpu-os') {
if (cmpName !== 0) return cmpName;
if (cmpGpu !== 0) return cmpGpu;
return cmpOs;
} else if (this.sortBy === 'gpu-os-name') {
if (cmpGpu !== 0) return cmpGpu;
if (cmpOs !== 0) return cmpOs;
return cmpName;
} else if (this.sortBy === 'os-gpu-name') {
if (cmpOs !== 0) return cmpOs;
if (cmpGpu !== 0) return cmpGpu;
return cmpName;
}
return 0;
});
return html`
<div style="position:absolute;width:200px;height:50px;background:white;z-index:10;">&nbsp;</div>
<div class="filter-bar">
<span style="font-weight: 600;">Backend Filter:</span>
<select style="font-size: 11px; padding: 4px;" @change="${(e) => this.backendFilter = e.target.value}">
<option value="all" ?selected="${this.backendFilter === 'all'}">opengl / vulkan</option>
<option value="opengl" ?selected="${this.backendFilter === 'opengl'}">opengl</option>
<option value="vulkan" ?selected="${this.backendFilter === 'vulkan'}">vulkan</option>
</select>
<div style="width: 1px; height: 20px; background: #ccc; margin: 0 10px;"></div>
<test-filter @filters-changed="${(e) => this.nameFilters = e.detail.filters}"></test-filter>
<div style="width: 1px; height: 20px; background: #ccc; margin: 0 10px;"></div>
<label style="display: flex; align-items: center; gap: 4px; font-weight: 600; font-size: 11px; cursor: pointer;">
<input type="checkbox" .checked="${this.showOnlyFailures}" @change="${(e) => this.showOnlyFailures = e.target.checked}">
Only columns with failed tests
</label>
</div>
<table>
<thead>
<tr>
<th class="device-col" style="font-size:15px; vertical-align: top; padding-top: 8px;">
<div>Device</div>
<select style="font-size: 11px; margin-top: 6px; width: 100%; padding: 2px;" @change="${(e) => this.sortBy = e.target.value}">
<option value="name-gpu-os" ?selected="${this.sortBy === 'name-gpu-os'}">Device -> GPU -> Android Ver</option>
<option value="gpu-os-name" ?selected="${this.sortBy === 'gpu-os-name'}">GPU -> Android Ver -> Device</option>
<option value="os-gpu-name" ?selected="${this.sortBy === 'os-gpu-name'}">Android Ver -> GPU -> Device</option>
</select>
<div style="margin-top: 8px;">
<test-filter label="GPU:" placeholder="Filter GPU..." @filters-changed="${(e) => this.gpuFilters = e.detail.filters}"></test-filter>
</div>
</th>
${testRow}
</tr>
</thead>
<tbody>
${
sortedResults.map(device => {
const processBuildNumber = (rawBuild) => {
if (rawBuild.indexOf(' ') >=0 && rawBuild.indexOf('dev-keys') >= 0) {
return rawBuild.split(' ')[2].split('.')[0];
}
return rawBuild.split('.')[0];
};
const androidVersion = device.metadata.android_version + " " +
processBuildNumber(device.metadata.android_build_number);
const glGPU = device.metadata.gpu_driver_info.opengl.split(' | ');
const driverInfo = device.metadata.gpu_driver_info.vulkan.split(' | ')[2];
const truncatedStyle = "display:block;width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
const marginLow = "margin-bottom:4px;"
const androidVerStyle = marginLow + (this.sortBy.startsWith('os') ? "color:#f09090;" : '');
const gpuStyle = marginLow + (this.sortBy.startsWith('gpu') ? "color:#f09090;" : '');
const hardwareStyle = marginLow + "color:#bbb;";
const deviceNameStyle = marginLow + "font-size:15px;font-weight:bold;";
return html`
<tr>
<td class="device-col">
<div style="${deviceNameStyle}">${device.metadata.device_name} </div>
<div style="${hardwareStyle}">${device.metadata.device_hardware} </div>
<div style="${androidVerStyle}">Android ${androidVersion}</div>
<div style="${gpuStyle}">${glGPU[0]} ${glGPU[1]}</div>
<div style="${truncatedStyle}">${driverInfo}</div>
</td>
${
testNames.map(testName => {
const run = device.runs.find(r => r.testName === testName);
if (!run) return html`<td>-</td>`;
return html`
<td class="result-cell">
<div class="result-container ${run.passed ? 'pass' : 'fail'}"
@click="${() => this._handleThumbnailClick(device.device, run)}">
<img class="thumb" src="${run.thumb}" loading="lazy" alt="Thumbnail" />
</div>
</td>
`;
}
)}
</tr>
`})
}
</tbody>
</table>
`;
}
_handleThumbnailClick(device, run) {
this.dispatchEvent(new CustomEvent('view-result', {
detail: { device, run },
bubbles: true,
composed: true
}));
}
}
customElements.define('result-table', ResultTable);

View File

@@ -0,0 +1,100 @@
import { html, css, LitElement } from 'lit';
export class TestFilter extends LitElement {
static properties = {
filters: { type: Array },
label: { type: String },
placeholder: { type: String }
};
constructor() {
super();
this.filters = [];
this.label = 'Test Name:';
this.placeholder = 'Filter tests...';
}
static styles = css`
:host {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
input {
padding: 4px;
font-size: 11px;
border: 1px solid #ccc;
border-radius: 4px;
max-width: 120px;
}
.tag {
display: inline-flex;
align-items: center;
background: #e0e0e0;
padding: 2px 6px;
border-radius: 12px;
font-size: 11px;
gap: 4px;
white-space: nowrap;
}
.remove-btn {
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.remove-btn:hover {
color: #000;
}
`;
render() {
return html`
${this.label ? html`<span style="font-weight: 600; font-size: 11px;">${this.label}</span>` : ''}
<input
type="text"
.placeholder="${this.placeholder}"
@keydown="${this._handleKeydown}"
>
${this.filters.map((filter, index) => html`
<div class="tag">
${filter}
<button class="remove-btn" @click="${() => this._removeFilter(index)}">×</button>
</div>
`)}
`;
}
_handleKeydown(e) {
if (e.key === 'Enter') {
const val = e.target.value.trim();
if (val && !this.filters.includes(val)) {
this.filters = [...this.filters, val];
this._dispatchChange();
}
e.target.value = '';
}
}
_removeFilter(index) {
this.filters = this.filters.filter((_, i) => i !== index);
this._dispatchChange();
}
_dispatchChange() {
this.dispatchEvent(new CustomEvent('filters-changed', {
detail: { filters: this.filters },
bubbles: true,
composed: true
}));
}
}
customElements.define('test-filter', TestFilter);

View File

@@ -0,0 +1,330 @@
// 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";
class ImageMagnifier extends LitElement {
static styles = css`
:host {
position: absolute;
pointer-events: none;
z-index: 1000;
display: none;
}
:host([visible]) {
display: block;
}
.magnifier {
width: 150px;
height: 150px;
border: 2px solid #000;
border-radius: 75px;
background: white;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
position: relative;
}
.magnifier-canvas {
width: 100%;
height: 100%;
border-radius: 73px;
}
.pixel-info {
position: absolute;
top: -40px;
left: 15px;
background: rgba(0,0,0,0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-family: monospace;
font-size: 9px;
white-space: pre-line;
max-width: 300px;
}
`;
static properties = {
visible: {type: Boolean, reflect: true},
};
constructor() {
super();
this.visible = false;
}
render() {
return html`
<div class="magnifier">
<canvas class="magnifier-canvas" id="magnifierCanvas"></canvas>
<div class="pixel-info" id="pixelInfo"></div>
</div>
`;
}
updateMagnifier(imageData, parentRect, imageX, imageY, originalImageData = null) {
const zoomFactor = 8;
if (!imageData) return;
if (imageX < 0 || imageX >= imageData.width || imageY < 0 || imageY >= imageData.height) {
this.visible = false;
return;
}
const pixelIndex = (imageY * imageData.width + imageX) * 4;
const r = imageData.data[pixelIndex];
const g = imageData.data[pixelIndex + 1];
const b = imageData.data[pixelIndex + 2];
const a = imageData.data[pixelIndex + 3];
let pixelInfoText = `(${r}, ${g}, ${b}, ${a})\n@ (${imageX}, ${imageY})`;
// If original image data is provided, show the unmultiplied values too
if (originalImageData) {
const origR = originalImageData.data[pixelIndex];
const origG = originalImageData.data[pixelIndex + 1];
const origB = originalImageData.data[pixelIndex + 2];
const origA = originalImageData.data[pixelIndex + 3];
pixelInfoText = `Orig: (${origR}, ${origG}, ${origB}, ${origA})\nMult: (${r}, ${g}, ${b}, ${a})\n@ (${imageX}, ${imageY})`;
}
const magnifierSize = 150;
const sourceSize = magnifierSize / zoomFactor;
const halfSource = sourceSize / 2;
const sourceX = Math.max(0, Math.min(imageData.width - sourceSize, imageX - halfSource));
const sourceY = Math.max(0, Math.min(imageData.height - sourceSize, imageY - halfSource));
const magnifierCanvas = this.shadowRoot.getElementById('magnifierCanvas');
const pixelInfo = this.shadowRoot.getElementById('pixelInfo');
magnifierCanvas.width = magnifierSize;
magnifierCanvas.height = magnifierSize;
const magnifierCtx = magnifierCanvas.getContext('2d');
magnifierCtx.imageSmoothingEnabled = false;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = imageData.width;
tempCanvas.height = imageData.height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.putImageData(imageData, 0, 0);
magnifierCtx.drawImage(
tempCanvas,
sourceX, sourceY, sourceSize, sourceSize,
0, 0, magnifierSize, magnifierSize
);
const centerX = magnifierSize / 2;
const centerY = magnifierSize / 2;
const lineWidth = 1;
const boxWidth = zoomFactor + lineWidth;
magnifierCtx.strokeStyle = 'red';
magnifierCtx.lineWidth = lineWidth;
magnifierCtx.beginPath();
magnifierCtx.moveTo(centerX, centerY);
magnifierCtx.lineTo(centerX + boxWidth, centerY);
magnifierCtx.lineTo(centerX + boxWidth, centerY + boxWidth);
magnifierCtx.lineTo(centerX, centerY + boxWidth);
magnifierCtx.lineTo(centerX, centerY);
magnifierCtx.stroke();
// Position relative to the TiffViewer container
this.style.left = Math.round(-centerX +
(imageX / imageData.width) * parentRect.width -
boxWidth) + 'px';
this.style.top = Math.round(-centerY +
(imageY / imageData.height) * parentRect.height -
boxWidth) + 'px';
pixelInfo.textContent = pixelInfoText;
this.visible = true;
}
hide() {
this.visible = false;
}
}
customElements.define('image-magnifier', ImageMagnifier);
// Generated by Gemini with some modifications
export class TiffViewer extends LitElement {
static styles = css`
:host {
display: block;
position: relative;
}
canvas {
border: 1px solid #ccc;
width: 100%;
height: 100%;
}
`;
static properties = {
fileurl: {type: String, attribute: 'fileurl'},
failedToFetch: {type: Boolean },
magnifierEnabled: {type: Boolean, attribute: 'magnifier-enabled'},
disableMouseHandlers: {type: Boolean, attribute: 'disable-mouse-handlers'},
srcdata: {type: Object, attribute: 'srcdata'},
};
constructor() {
super();
this.fileurl = null;
this.failedToFetch = false;
this.magnifierEnabled = false;
this.disableMouseHandlers = false;
this.imgdata = null;
this.srcdata = null;
this.canvasRect = null;
}
render() {
if (this.failedToFetch) {
return html``;
}
return html`
<canvas id="tiffCanvas"
@mousemove="${this._onMouseMove}"
@mouseenter="${this._onMouseEnter}"
@mouseleave="${this._onMouseLeave}">
</canvas>
<image-magnifier id="magnifier"></image-magnifier>
`;
}
updated(props) {
if (props.has('fileurl') && this.fileurl) {
this._updateImage(this.fileurl);
return;
}
if (props.has('srcdata') && this.srcdata) {
this._drawImage(this.srcdata);
}
}
_drawImage(imageData) {
const canvas = this.shadowRoot.getElementById('tiffCanvas');
const ctx = canvas.getContext('2d');
canvas.width = imageData.width;
canvas.height = imageData.height;
ctx.putImageData(imageData, 0, 0);
this.imgdata = imageData;
}
async _updateImage(fileurl) {
this.failedToFetch = false;
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = () => {
const canvas = this.shadowRoot.getElementById('tiffCanvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
// The default mode would set alpha to 1 so that RGB differences would be displayed as non-transparent
for (let i = 3; i < imageData.data.length; i += 4) {
imageData.data[i] = 255;
}
ctx.putImageData(imageData, 0, 0);
this.imgdata = imageData;
this.dispatchEvent(new CustomEvent('url-hit', {
detail: { value: fileurl },
bubbles: true,
composed: true,
}));
this.dispatchEvent(new CustomEvent('image-loaded', {
bubbles: true,
composed: true,
detail: {
url: this.fileurl,
img: imageData,
}
}));
};
img.onerror = () => {
this.failedToFetch = true;
this.dispatchEvent(new CustomEvent('url-miss', {
detail: { value: fileurl },
bubbles: true,
composed: true,
}));
this._clearCanvas();
};
img.src = fileurl;
}
_clearCanvas() {
const canvas = this.shadowRoot.getElementById('tiffCanvas');
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
_onMouseEnter(event) {
if (this.disableMouseHandlers || !this.magnifierEnabled || !this.imgdata) return;
this.canvasRect = event.target.getBoundingClientRect();
}
_onMouseLeave(event) {
if (this.disableMouseHandlers || !this.magnifierEnabled) return;
const magnifier = this.shadowRoot.getElementById('magnifier');
magnifier.hide();
}
_onMouseMove(event) {
if (this.disableMouseHandlers || !this.canvasRect) return;
const rect = this.canvasRect;
const scaleX = this.imgdata.width / rect.width;
const scaleY = this.imgdata.height / rect.height;
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const imageX = Math.floor(mouseX * scaleX);
const imageY = Math.floor(mouseY * scaleY);
this.updateMagnifier(imageX, imageY);
}
updateMagnifier(imageX, imageY, origData = null) {
let rect = null;
const canvas = this.shadowRoot.getElementById('tiffCanvas');
if (canvas) {
rect = canvas.getBoundingClientRect();
}
if (!this.magnifierEnabled || !this.imgdata || !rect) return;
const magnifier = this.shadowRoot.getElementById('magnifier');
magnifier.updateMagnifier(this.imgdata, rect, imageX, imageY, origData);
}
}
customElements.define('tiff-viewer', TiffViewer);

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

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Render Validation Results</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0px;
background-color: #f5f5f5;
color: #333;
height: 100%;
}
h1 {
color: #2c3e50;
margin-bottom: 20px;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
#app {
margin: 0 auto;
height: 100%;
}
.loading {
text-align: center;
font-size: 1.2em;
color: #7f8c8d;
margin-top: 50px;
}
#container {
position: relative;
width: 100%;
}
</style>
<!-- Use ES modules to load Lit -->
<script type="importmap">
{
"imports": {
"lit": "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js"
}
}
</script>
</head>
<body>
<div id="app">
<div id="container">
<div class="loading">Loading data...</div>
</div>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>

View File

@@ -135,11 +135,8 @@ class FileItem(Static):
with Horizontal(id="file_row", classes="file-item-row"): with Horizontal(id="file_row", classes="file-item-row"):
yield Label(self.filename, id="lbl_filename", classes="file-name") yield Label(self.filename, id="lbl_filename", classes="file-name")
# Only show Load button for test configurations, not results
if not self.filename.startswith("results_"): if not self.filename.startswith("results_"):
yield Button("", id="btn_load", variant="success", classes="compact-btn", tooltip="Load this test on device") yield Button("", id="btn_load", variant="success", classes="compact-btn", tooltip="Load this test on device")
else:
yield Button("🌐", id="btn_serve", variant="success", classes="compact-btn", tooltip="Serve and view results locally")
yield Button("", id="btn_download", variant="primary", classes="compact-btn", tooltip="Download to PC") yield Button("", id="btn_download", variant="primary", classes="compact-btn", tooltip="Download to PC")
yield Button("", id="btn_start_rename", variant="warning", classes="compact-btn", tooltip="Rename on device") yield Button("", id="btn_start_rename", variant="warning", classes="compact-btn", tooltip="Rename on device")
@@ -148,11 +145,9 @@ class FileItem(Static):
yield Input(value=self.filename, id="inp_rename", classes="rename-input") yield Input(value=self.filename, id="inp_rename", classes="rename-input")
yield Button("Save", id="btn_save_rename", variant="success", classes="compact-btn") yield Button("Save", id="btn_save_rename", variant="success", classes="compact-btn")
yield Button("Cancel", id="btn_cancel_rename", classes="compact-btn") yield Button("Cancel", id="btn_cancel_rename", classes="compact-btn")
yield Label("", id="lbl_server_url", classes="server-url")
def on_mount(self): def on_mount(self):
self.query_one("#rename_row").display = False self.query_one("#rename_row").display = False
self.query_one("#lbl_server_url").display = False
async def on_button_pressed(self, event: Button.Pressed) -> None: async def on_button_pressed(self, event: Button.Pressed) -> None:
btn_id = event.button.id btn_id = event.button.id
@@ -210,81 +205,8 @@ class FileItem(Static):
self.app.notify(f"Loading {self.filename} on device...", title="Load Test") self.app.notify(f"Loading {self.filename} on device...", title="Load Test")
self.run_worker(self.load_on_device(), exclusive=True) self.run_worker(self.load_on_device(), exclusive=True)
elif btn_id == "btn_serve":
if hasattr(self, "server_proc") and self.server_proc:
self.stop_server(event.button)
else:
event.button.disabled = True
self.run_worker(self.start_server(event.button), exclusive=True)
async def start_server(self, button: Button) -> None:
try:
# Create a tmp directory for the results
tmp_dir = os.path.join(os.getcwd(), "tmp")
os.makedirs(tmp_dir, exist_ok=True)
dest = os.path.join(tmp_dir, self.filename)
# Download file to tmp
if not os.path.exists(dest):
self.app.notify(f"Downloading {self.filename} for viewer...", title="Preparing Server")
if self.is_internal:
cmd = f"adb -s {self.serial} shell \"run-as {PACKAGE} cat {self.filepath}\" > \"{dest}\""
proc = await asyncio.create_subprocess_shell(cmd)
await proc.communicate()
else:
await run_adb_cmd("-s", self.serial, "pull", self.filepath, dest)
# Find unoccupied port
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("", 0))
port = s.getsockname()[1]
s.close()
# Start server
server_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "result-viewer", "server.py")
self.server_proc = await asyncio.create_subprocess_exec(
"python3", server_script, dest, "--port", str(port)
)
button.label = "🛑"
button.variant = "error"
button.tooltip = "Stop Server"
lbl_url = self.query_one("#lbl_server_url", Label)
lbl_url.update(f" ↳ Server: http://localhost:{port}")
lbl_url.display = True
self.app.notify(f"Result viewer started on http://localhost:{port}", title="Server Started")
except Exception as e:
self.app.notify(f"Failed to start server: {e}", title="Server Error", severity="error")
button.label = "🌐"
button.variant = "success"
button.tooltip = "Serve and view results locally"
self.query_one("#lbl_server_url", Label).display = False
self.server_proc = None
finally:
button.disabled = False
def stop_server(self, button: Button = None) -> None:
if hasattr(self, "server_proc") and self.server_proc:
try:
self.server_proc.terminate()
except ProcessLookupError:
pass
self.server_proc = None
if button:
button.label = "🌐"
button.variant = "success"
button.tooltip = "Serve and view results locally"
try:
self.query_one("#lbl_server_url", Label).display = False
except Exception:
pass
self.app.notify("Server stopped", title="Server Stopped")
def on_unmount(self) -> None: def on_unmount(self) -> None:
self.stop_server() pass
async def load_on_device(self) -> None: async def load_on_device(self) -> None:
cmd_args = [ cmd_args = [