squash commits

This commit is contained in:
MrPlatnum committed 2025-09-05 15:20:42 +02:00
commit 0f545c5e4d
611 files changed
+272565

No files matched your search

@@ -0,0 +1,391 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 {promises as fs} from 'fs';
import {mkdirp} from 'mkdirp';
import {join, resolve} from 'path';
import pngjs from 'pngjs';
import puppeteer, {Browser} from 'puppeteer';
import {DEVICE_PIXEL_RATIO, Dimensions, FIDELITY_TEST_THRESHOLD, FidelityRegressionResults, GoldenConfig, ImageComparator, ImageComparisonAnalysis, ImageComparisonConfig, ScenarioConfig, toDecibel} from './common.js';
import {ConfigReader} from './config-reader.js';
const $configReader = Symbol('configReader');
export type AnalysisResults = Array<ImageComparisonAnalysis>;
export interface ScenarioRecord extends ScenarioConfig {
analysisResults: AnalysisResults;
}
export class ArtifactCreator {
private[$configReader]: ConfigReader = new ConfigReader(this.config);
private browser: Browser|null = null;
constructor(
protected config: ImageComparisonConfig, protected rootDirectory: string,
protected baseUrl: string) {
console.log('🌈 Preparing to capture screenshots for fidelity comparison');
}
async close() {
if (this.browser != null) {
await this.browser.close();
this.browser = null;
}
}
protected get outputDirectory(): string {
return join(resolve(this.rootDirectory), 'results');
}
protected get goldens(): Array<GoldenConfig> {
return this.config.renderers.map(
renderer => ({...renderer, file: `${renderer.name}-golden.png`}));
}
async compareRenderers(scenario: ScenarioConfig, renderer: string) {
const analysisResults: AnalysisResults = [];
const {rootDirectory, outputDirectory, goldens} = this;
const {name: scenarioName, exclude, dimensions} = scenario;
// skip if this renderer is excluded from this scenario
if (exclude != null && exclude.includes(renderer)) {
return;
}
console.log(
`Start to compare ${renderer}'s golden with other renderers' goldens:`);
const rendererIndex = 0;
const rendererGoldenPath = join(
rootDirectory, 'goldens', scenarioName, goldens[rendererIndex].file);
let rendererGolden;
try {
rendererGolden = await fs.readFile(rendererGoldenPath);
} catch (error) {
throw new Error(`❌ Failed to read ${renderer}'s ${
scenarioName} golden! Error message: ${error.message}`);
}
// save goldens images to result folder which will be used to show in the
// result-viewer page.
await fs.writeFile(
join(outputDirectory, scenarioName, goldens[rendererIndex].file),
rendererGolden);
const rendererGoldenImage =
new Uint8ClampedArray(pngjs.PNG.sync.read(rendererGolden).data);
for (const golden of goldens) {
if (golden.name === renderer ||
(exclude != null && exclude.includes(golden.name))) {
continue;
}
console.log(`\n🔍 Comparing ${renderer} to ${golden.description}`);
const candidateGoldenPath =
join(rootDirectory, 'goldens', scenarioName, golden.file);
let candidateGolden;
try {
candidateGolden = await fs.readFile(candidateGoldenPath);
} catch (error) {
throw new Error(`❌ Failed to read ${golden.name}'s ${
scenarioName} golden! Error message: ${error.message}`);
}
// save goldens images to result folder which will be used to show in the
// result-viewer page.
await fs.writeFile(
join(outputDirectory, scenarioName, golden.file), candidateGolden);
const candidateGoldenImage =
new Uint8ClampedArray(pngjs.PNG.sync.read(candidateGolden).data);
const analysisResult = await this.analyze(
rendererGoldenImage, candidateGoldenImage, dimensions);
analysisResults.push(analysisResult);
}
const scenarioRecord = {analysisResults, scenario};
console.log(`\n💾 Recording analysis`);
await fs.writeFile(
join(outputDirectory, scenarioName, 'analysis.json'),
JSON.stringify(scenarioRecord));
}
async captureAndAnalyzeScreenshot(
scenario: ScenarioConfig, renderer: string,
quiet: boolean = false): Promise<ImageComparisonAnalysis> {
const {rootDirectory, goldens} = this;
const {name: scenarioName, dimensions, exclude} = scenario;
console.log(`start compare ${renderer}'s golden with ${
renderer}'s screenshot generated from fidelity test:`);
let screenshot: Uint8Array|undefined;
try {
// set the output path to an empty string to tell puppeteer to not save
// the screenshot image
screenshot = await this.captureScreenshot(
renderer, scenarioName, dimensions, '', 60, quiet);
} catch (error) {
if (this.browser != null) {
await this.browser.close();
this.browser = null;
}
throw new Error(`❌ Failed to capture ${renderer}'s screenshot of ${
scenarioName}. Error message: ${error.message}`);
}
if (screenshot == null) {
throw new Error(`❌ ${renderer}'s screenshot of ${
scenarioName} is not captured correctly (value is null).`);
}
const buffer = Buffer.from(screenshot);
const screenshotImage =
new Uint8ClampedArray(pngjs.PNG.sync.read(buffer).data);
const rendererIndex = 0;
const rendererGoldenPath = join(
rootDirectory, 'goldens', scenarioName, goldens[rendererIndex].file);
let rendererGolden;
try {
rendererGolden = await fs.readFile(rendererGoldenPath);
} catch (error) {
throw new Error(`❌ Failed to read ${renderer}'s ${
scenarioName} golden! Error message: ${error.message}`);
}
const rendererGoldenImage =
new Uint8ClampedArray(pngjs.PNG.sync.read(rendererGolden).data);
const result =
await this.analyze(screenshotImage, rendererGoldenImage, dimensions);
const rmsInDb = toDecibel(result.rmsDistanceRatio);
// the rmsInDb is negative, and the less negative means the less closer the
// two images are
if (rmsInDb > FIDELITY_TEST_THRESHOLD) {
if (exclude?.includes(renderer)) {
console.log(`❌ Skipped! Senario name: ${
scenario.name}, rms distance ratio: ${rmsInDb.toFixed(2)} dB.`);
} else {
throw new Error(`❌ Senarios name: ${
scenario.name}, rms distance ratio: ${rmsInDb.toFixed(2)} dB.`);
}
}
return result;
}
async fidelityTest(
scenarioWhitelist: Set<string>|null = null, renderer: string,
dryRun: boolean = false, quiet: boolean = false) {
const {scenarios} = this.config;
const {outputDirectory} = this;
const analyzedScenarios: Array<ScenarioConfig> = [];
const fidelityRegressionResults:
FidelityRegressionResults = {results: [], errors: [], warnings: []};
const compareRenderersErrors: Array<string> = [];
for (const scenarioBase of scenarios) {
const scenarioName = scenarioBase.name;
const scenario = this[$configReader].scenarioConfig(scenarioName)!;
// skip if white lists exists and this scenario isn't in it
if (scenarioWhitelist != null && !scenarioWhitelist.has(scenarioName)) {
continue;
}
// skip if this renderer is excluded from this scenario
if (scenario.exclude != null && scenario.exclude.includes(renderer)) {
continue;
}
console.log(`\n🎨 Scenario: ${scenarioName}`);
const scenarioOutputDirectory = join(outputDirectory, scenarioName);
mkdirp.sync(scenarioOutputDirectory);
try {
await this.compareRenderers(scenario, renderer);
} catch (error) {
const errorMessage =
`❌Fail to compare ${renderer} with other renderers of scenario ${
scenarioName}. Error message: ${error.message}`;
compareRenderersErrors.push(errorMessage);
}
if (!dryRun) {
try {
const autoTestResult =
await this.captureAndAnalyzeScreenshot(scenario, renderer, quiet);
fidelityRegressionResults.results.push(autoTestResult);
} catch (error) {
const message = `❌Fail to analyze scenario :${
scenarioName}! Error message: ${error.message}`;
fidelityRegressionResults.errors.push(message);
}
analyzedScenarios.push(scenario);
}
}
console.log('💾 Recording configuration');
const finalConfig: ImageComparisonConfig = Object.assign(
{},
this.config,
{scenarios: analyzedScenarios, errors: compareRenderersErrors});
// ensure directory exists, this can happen if all scenarios are ignored
await fs.mkdir(outputDirectory, {recursive: true});
await fs.writeFile(
join(outputDirectory, 'config.json'), JSON.stringify(finalConfig));
await fs.writeFile(
join(outputDirectory, 'fidelityRegressionResults.json'),
JSON.stringify(fidelityRegressionResults));
}
protected async analyze(
candidateImage: Uint8ClampedArray, goldenImage: Uint8ClampedArray,
dimensions: Dimensions): Promise<ImageComparisonAnalysis> {
const imageDimensions = {
width: dimensions.width * DEVICE_PIXEL_RATIO,
height: dimensions.height * DEVICE_PIXEL_RATIO
};
const comparator =
new ImageComparator(candidateImage, goldenImage, imageDimensions);
const {analysis} = comparator.analyze();
const {rmsDistanceRatio} = analysis;
console.log(`\n 📊 Decibels of root mean square color distance: ${
(10 * Math.log10(rmsDistanceRatio)).toFixed(2)}`);
return analysis;
}
async captureScreenshot(
renderer: string, scenarioName: string, dimensions: Dimensions,
outputPath: string = join(this.outputDirectory, 'model-viewer.png'),
maxTimeInSec: number = -1, quiet: boolean = false) {
const scaledWidth = dimensions.width;
const scaledHeight = dimensions.height;
const rendererConfig = this[$configReader].rendererConfig(renderer);
if (rendererConfig == null) {
console.log(`⚠️ Renderer "${
renderer}" is not configured. Did you add it to the test config?`);
return;
}
if (this.browser == null) {
console.log(`🚀 Launching browser`);
// no-sandbox and disable-setuid-sandbox args to resolve puppeteer browser run error in fidelity tests
this.browser = await puppeteer.launch({headless: quiet, args:['--no-sandbox', '--disable-setuid-sandbox']});
}
const page = await this.browser.newPage();
const url = `${this.baseUrl}?hide-ui&config=../../config.json&scenario=${
encodeURIComponent(scenarioName)}`;
await page.setViewport({
width: scaledWidth,
height: scaledHeight,
deviceScaleFactor: DEVICE_PIXEL_RATIO
});
page.on('error', (error: any) => {
console.log(`🚨 ${error}`);
});
page.on('console', async (message: any) => {
const args =
await Promise.all(message.args().map((arg: any) => arg.jsonValue()));
if (args.length) {
console.log(`➡️`, ...args);
}
});
console.log(`🗺 Navigating to ${url}`);
await page.goto(url);
console.log(
`🖌 Rendering ${scenarioName} with ${rendererConfig.description}`);
// NOTE: The function passed to page.evaluate is stringified and eval'd
// in a browser context. Importantly, this implies that no external
// variables are captured in its closure scope. TypeScript compiler
// currently has no mechanism to detect this and will happily tell you
// your code is correct when it isn't.
const evaluateError = await page.evaluate(async (maxTimeInSec: number) => {
const modelBecomesReady = new Promise<void>((resolve, reject) => {
let timeout: NodeJS.Timeout;
if (maxTimeInSec > 0) {
timeout = setTimeout(() => {
reject(new Error(
`Stop capturing screenshot after ${maxTimeInSec} seconds`));
}, maxTimeInSec * 1000);
}
self.addEventListener('model-ready', () => {
if (maxTimeInSec > 0) {
clearTimeout(timeout);
}
resolve();
}, {once: true});
});
try {
await modelBecomesReady;
return null;
} catch (error) {
return error.message;
}
}, maxTimeInSec);
if (evaluateError) {
console.log(evaluateError);
await this.browser.close();
this.browser = null;
throw new Error(evaluateError);
}
console.log(`🖼 Capturing screenshot`);
try {
await fs.mkdir(this.outputDirectory);
} catch (e) {
// Ignored...
}
const screenshot =
await page.screenshot({path: outputPath as `${string}.png`, omitBackground: true});
page.close();
return screenshot;
}
}
@@ -0,0 +1,295 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 {colorDelta} from './third_party/pixelmatch/color-delta.js';
export interface OffscreenCanvas extends HTMLCanvasElement {}
export const COMPONENTS_PER_PIXEL: number = 4;
// 35215 is the maximum possible value for the YIQ difference metric
// @see https://github.com/mapbox/pixelmatch/blob/master/index.js#L14
// @see http://www.progmat.uaem.mx:8080/artVol2Num2/Articulo3Vol2Num2.pdf
export const MAX_COLOR_DISTANCE: number = 35215;
export const DEVICE_PIXEL_RATIO: number = 2;
// use this threshold to do automatic fidelity test for model-viewer. any
// scenario whose rms value (in dB) is bigger than the threshold will fail.
export const FIDELITY_TEST_THRESHOLD: number = -22;
export interface FidelityRegressionResults {
results: Array<ImageComparisonAnalysis>;
warnings: Array<string>;
errors: Array<string>;
}
export interface ImageComparisonAnalysis {
rmsDistanceRatio: number;
}
export interface ImageComparisonResults {
analysis: ImageComparisonAnalysis
}
export interface Visuals {
imageBuffers: {delta: ArrayBuffer|null; blackWhite: ArrayBuffer | null;};
}
export interface ScenarioRecord {
analysisResults: Array<ImageComparisonAnalysis>;
scenarioConfig: ScenarioConfig;
}
export interface ImageComparisonMessage {
type: 'canvases-ready'|'images-assigned'|'threshold-changed'|
'analysis-completed';
}
export interface CanvasesReadyMessage extends ImageComparisonMessage {
candidateCanvas: OffscreenCanvas;
goldenCanvas: OffscreenCanvas;
blackWhiteCanvas: OffscreenCanvas;
deltaCanvas: OffscreenCanvas;
}
export interface ImagesAssignedMessage extends ImageComparisonMessage {
candidateImageBuffer: ArrayBuffer;
goldenImageBuffer: ArrayBuffer;
dimensions: Dimensions;
}
export interface ThresholdChangedMessage extends ImageComparisonMessage {
threshold: number;
}
export interface AnalysisCompletedMessage extends ImageComparisonMessage {
result: Visuals
}
export interface Dimensions {
width: number;
height: number;
}
export interface Pixel {
x: number;
y: number;
}
export interface Rect extends Dimensions {
x: number;
y: number;
}
export interface ScenarioConfig {
name: string;
model: string;
lighting: string;
dimensions: Dimensions;
target: {x: number, y: number, z: number};
orbit: {theta: number, phi: number, radius: number};
verticalFoV: number;
exclude?: Array<string>;
renderSkybox: boolean;
pt?: {numSamples?: number};
}
export interface RendererConfig {
name: string,
description: string,
scripts?: {setup: string},
command?: {
executable?: string,
args?: string[]
}
}
export interface GoldenConfig extends RendererConfig {
file: string;
}
export interface ImageComparisonConfig {
rootDirectory: string;
analysisThresholds: Array<number>;
renderers: Array<RendererConfig>;
scenarios: Array<ScenarioConfig>;
}
export interface AnalysisOptions {
generateVisuals: boolean;
}
export class ImageComparator {
protected imagePixels: number;
constructor(
protected candidateImage: Uint8ClampedArray,
protected goldenImage: Uint8ClampedArray,
readonly dimensions: Dimensions) {
const {width, height} = dimensions;
this.imagePixels = width * height;
}
protected drawPixel(
image: Uint8ClampedArray, position: number, r: number, g: number,
b: number, a: number = 255) {
image[position + 0] = r;
image[position + 1] = g;
image[position + 2] = b;
image[position + 3] = a;
}
generateVisuals(threshold: number): Visuals {
const {candidateImage, goldenImage} = this;
const {width, height} = this.dimensions;
const blackWhiteImage =
new Uint8ClampedArray(this.imagePixels * COMPONENTS_PER_PIXEL);
const deltaImage =
new Uint8ClampedArray(this.imagePixels * COMPONENTS_PER_PIXEL);
const thresholdSquared = threshold * threshold;
let maximumDeltaIntensity = 0;
if (candidateImage.length != goldenImage.length) {
throw new Error(`Image sizes do not match (candidate: ${
candidateImage.length}, golden: ${goldenImage.length})`);
}
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
const index = y * width + x;
const position = index * COMPONENTS_PER_PIXEL;
const delta =
colorDelta(candidateImage, goldenImage, position, position);
const exactlyMatched = (delta <= thresholdSquared ? 1 : 0) * 255;
const thresholdDelta = Math.max(0, delta - thresholdSquared);
const deltaIntensity =
Math.round(255 * thresholdDelta / MAX_COLOR_DISTANCE);
maximumDeltaIntensity = Math.max(deltaIntensity, maximumDeltaIntensity);
this.drawPixel(
blackWhiteImage,
position,
exactlyMatched,
exactlyMatched,
exactlyMatched);
this.drawPixel(
deltaImage,
position,
255,
255 - deltaIntensity,
255 - deltaIntensity);
}
}
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
const index = y * width + x;
const position = index * COMPONENTS_PER_PIXEL;
const absoluteDeltaIntensity = 255 - deltaImage![position + 1];
const relativeDeltaIntensity = Math.round(
255 - 255 * (absoluteDeltaIntensity / maximumDeltaIntensity));
this.drawPixel(
deltaImage,
position,
255,
relativeDeltaIntensity,
relativeDeltaIntensity);
}
}
return {
imageBuffers:
{delta: deltaImage.buffer, blackWhite: blackWhiteImage.buffer}
};
}
analyze(): ImageComparisonResults {
const {candidateImage, goldenImage} = this;
const {width, height} = this.dimensions;
let squareSum = 0;
if (candidateImage.length != goldenImage.length) {
throw new Error(`Image sizes do not match (candidate: ${
candidateImage.length}, golden: ${goldenImage.length})`);
}
let modelPixelCount = 0;
let colorlessPixelCount = 0;
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
const index = y * width + x;
// image's pixel data is stored in an 1-D array, 1st row sequentialy,
// then 2nd row, .. for each pixel, its data is stored by order of r, g,
// b, a. here position is the index for current pixel's r , position+3
// is index for its alpha
const position = index * COMPONENTS_PER_PIXEL;
// alpha is in range 0~255 here, map it to 0~1
const alpha = candidateImage[position + 3] / 255;
let isWhitePixel = true;
let isBlackPixel = true;
for (let i = 0; i < 3; i++) {
const colorComponent = candidateImage[position + i] * alpha;
if (colorComponent != 255) {
isWhitePixel = false;
}
if (colorComponent != 0) {
isBlackPixel = false;
}
}
if (isBlackPixel || isWhitePixel) {
colorlessPixelCount++;
}
if (alpha === 0) {
continue;
}
const delta =
colorDelta(candidateImage, goldenImage, position, position);
squareSum += delta * delta;
modelPixelCount++;
}
}
const imagePixelCount = width * height;
if (colorlessPixelCount === imagePixelCount) {
throw new Error('Candidate image is colorless!');
}
const rmsDistanceRatio =
Math.sqrt(squareSum / modelPixelCount) / MAX_COLOR_DISTANCE;
return {analysis: {rmsDistanceRatio}};
}
}
export function toDecibel(value: number): number {
return 10 * Math.log10(value);
}
@@ -0,0 +1,394 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 './images-4-up.js';
import '@polymer/paper-slider';
import {html, LitElement} from 'lit';
import {property} from 'lit/decorators.js';
import {AnalysisCompletedMessage, Dimensions, ImageComparisonMessage, Visuals} from '../common.js';
import {ImageAccessor} from '../image-accessor.js';
export class AnalysisView extends LitElement {
@property({type: Object}) leftImage: HTMLImageElement|null = null;
@property({type: Object}) rightImage: HTMLImageElement|null = null;
@property({type: Object}) visuals: Visuals|null = null;
@property({type: Object})
protected leftImageAccessor: ImageAccessor|null = null;
@property({type: Object})
protected rightImageAccessor: ImageAccessor|null = null;
@property({type: Object})
protected blackWhiteImageAccessor: ImageAccessor|null = null;
@property({type: Object})
protected deltaImageAccessor: ImageAccessor|null = null;
@property({type: Number}) protected threshold: number = 5;
protected worker: Worker;
protected port: MessagePort;
constructor() {
super();
const channel = new MessageChannel();
this.port = channel.port1;
this.worker = new Worker('../dist/image-comparison-worker.js');
this.worker.postMessage('connect', [channel.port2]);
this.port.onmessage = (event) => this.onMessage(event);
this.port.start();
}
onMessage(event: MessageEvent) {
const data = event.data as ImageComparisonMessage;
const {type} = data;
switch (type) {
case 'analysis-completed':
this.visuals = (data as AnalysisCompletedMessage).result;
break;
default:
break;
}
}
async connectedCallback() {
super.connectedCallback && super.connectedCallback();
await this.updateComplete;
const candidateCanvas =
(this.shadowRoot!.querySelector('canvas[slot="top-left"]')! as any)
.transferControlToOffscreen();
const goldenCanvas =
(this.shadowRoot!.querySelector('canvas[slot="top-right"]')! as any)
.transferControlToOffscreen();
const blackWhiteCanvas =
(this.shadowRoot!.querySelector('canvas[slot="bottom-left"]')! as any)
.transferControlToOffscreen();
const deltaCanvas =
(this.shadowRoot!.querySelector('canvas[slot="bottom-right"]')! as any)
.transferControlToOffscreen();
this.port.postMessage(
{
type: 'canvases-ready',
candidateCanvas,
goldenCanvas,
blackWhiteCanvas,
deltaCanvas
},
[candidateCanvas, goldenCanvas, blackWhiteCanvas, deltaCanvas]);
}
pick(originatingElement: HTMLImageElement) {
if ((originatingElement.parentNode! as HTMLElement)
.classList.contains('selected')) {
this.deselect(originatingElement);
} else {
this.select(originatingElement);
}
}
get canCompareImages(): boolean {
return this.leftImage != null && this.rightImage != null;
}
deselect(element: HTMLImageElement) {
if (this.leftImage === element) {
this.leftImage = this.rightImage;
this.rightImage = null;
} else if (this.rightImage === element) {
this.rightImage = null;
}
(element.parentNode! as HTMLElement).classList.remove('selected');
}
select(element: HTMLImageElement) {
if (this.leftImage == null) {
this.leftImage = element;
} else if (this.rightImage == null) {
this.rightImage = element;
} else {
return;
}
(element.parentNode! as HTMLElement).classList.add('selected');
}
enterAnalysisView() {
if (this.canCompareImages) {
this.classList.add('compare');
}
}
exitAnalysisView() {
this.classList.remove('compare');
}
reset() {
if (this.rightImage != null) {
this.deselect(this.rightImage);
}
if (this.leftImage != null) {
this.deselect(this.leftImage);
}
}
get comparisonDimensions(): Dimensions {
if (this.canCompareImages) {
const width =
Math.max(this.leftImage!.naturalWidth, this.rightImage!.naturalWidth);
const height = Math.max(
this.leftImage!.naturalHeight, this.rightImage!.naturalHeight);
return {width, height};
}
return {width: 0, height: 0};
}
async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('analysisResult') && this.visuals != null &&
this.canCompareImages) {
const {imageBuffers} = this.visuals;
const {width, height} = this.comparisonDimensions;
this.blackWhiteImageAccessor = ImageAccessor.fromArrayBuffer(
imageBuffers.blackWhite!, width, height);
this.deltaImageAccessor =
ImageAccessor.fromArrayBuffer(imageBuffers.delta!, width, height);
}
if ((changedProperties.has('leftImage') ||
changedProperties.has('rightImage')) &&
this.canCompareImages) {
this.leftImageAccessor = ImageAccessor.fromImageElement(this.leftImage!);
this.rightImageAccessor =
ImageAccessor.fromImageElement(this.rightImage!);
const {width, height} = this.comparisonDimensions;
const candidateImageBuffer = this.leftImageAccessor.toArrayBuffer();
const goldenImageBuffer = this.rightImageAccessor.toArrayBuffer();
this.port.postMessage(
{
type: 'images-assigned',
candidateImageBuffer,
goldenImageBuffer,
dimensions: {width, height}
},
[
candidateImageBuffer as ArrayBuffer,
goldenImageBuffer as ArrayBuffer
]);
this.port.postMessage(
{type: 'threshold-changed', threshold: this.threshold});
}
if (changedProperties.has('threshold') && this.canCompareImages) {
this.port.postMessage(
{type: 'threshold-changed', threshold: this.threshold});
}
}
render() {
let instructions;
if (this.canCompareImages) {
instructions = html`
<button @click="${() => this.enterAnalysisView()}">
Compare selected images
</button>
<button @click="${() => this.reset()}" class="no-border">
Clear selections
</button>`;
} else {
instructions = html`<p>Select two images to compare them!</p>`;
}
return html`
<style>
:host {
display: block;
position: fixed;
width: 100vw;
bottom: 0;
left: 0;
font-family: Google Sans, sans-serif;
background-color: #37474f;
color: #fff;
box-shadow: 0px 3px 12px rgba(100, 100, 100, 0.3);
}
#instructions {
text-align: center;
font-style: italic;
padding: 1em;
}
button {
font-family: Google Sans, sans-serif;
text-transform: uppercase;
color: #fff;
background-color: #37474f;
border: 1px solid #fff;
border-radius: 3px;
font-size: 1.1em;
font-weight: 200;
padding: 0.45em 0.85em;
cursor: pointer;
}
button {
margin: 0.35em 0;
}
button:hover, button:focus {
background-color: #62727b;
}
#comparison {
display: flex;
position: fixed;
flex-direction: column;
align-items: stretch;
justify-content: center;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
background-color: #37474f;
transform: translateY(1vh);
opacity: 0;
pointer-events: none;
transition: transform 0.3s, opacity 0.3s;
}
:host(.compare) #comparison {
transform: translateY(0);
opacity: 1;
pointer-events: unset;
}
button.no-border {
border: none;
}
.exit {
margin: 0;
border: 0;
font-size: 0;
line-height: 0;
position: absolute;
right: 32px;
top: 32px;
width: 32px;
height: 32px;
opacity: 0.75;
}
.exit:hover {
opacity: 1;
}
.exit:before, .exit:after {
content: ' ';
position: absolute;
top: 0px;
left: 15px;
height: 33px;
width: 2px;
background-color: #fff;
}
.exit:before {
transform: rotate(45deg);
}
.exit:after {
transform: rotate(-45deg);
}
images-4-up {
flex: 1 1 100%;
}
#controls {
display: flex;
align-items: center;
justify-content: center;
flex: 1 1 90px;
color: #fff;
--primary-text-color: #fff;
--paper-slider-active-color: #fff;
--paper-slider-pin-color: #fff;
--paper-slider-knob-color: #fff;
--paper-slider-font-color: #444;
}
canvas {
width: 100%;
height: 100%;
}
</style>
<div id="instructions">
${instructions}
</div>
<div id="comparison">
<images-4-up .dimensions="${this.comparisonDimensions}"
.topLeftImageAccessor="${this.leftImageAccessor}"
.topRightImageAccessor="${this.rightImageAccessor}"
.bottomLeftImageAccessor="${this.blackWhiteImageAccessor}"
.bottomRightImageAccessor="${this.deltaImageAccessor}">
<canvas slot="top-left"></canvas>
<canvas slot="top-right"></canvas>
<canvas slot="bottom-left"></canvas>
<canvas slot="bottom-right"></canvas>
</images-4-up>
<div id="controls">
<label id="threshold-label">Threshold</label>
<paper-slider
@change="${
(event: Event) => this.threshold =
parseInt((event.target as HTMLInputElement).value, 10)}"
aria-labelledby="threshold-label"
min="0" max="10" step="1" pin snaps value="5" editable>
</paper-slider>
</div>
<button class="exit" @click="${() => this.exitAnalysisView()}">Exit</button>
</div>`
}
}
customElements.define('analysis-view', AnalysisView);
@@ -0,0 +1,108 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 './analysis-view.js';
import './rendering-scenario.js';
import {html, LitElement} from 'lit';
import {property, query} from 'lit/decorators.js';
import {ImageComparisonConfig} from '../common.js';
import {AnalysisView} from './analysis-view.js';
export class ImageComparisonApp extends LitElement {
@property({type: String}) src: string = '';
@property({type: Object}) config: ImageComparisonConfig|null = null;
@query('analysis-view') analysisView!: AnalysisView;
updated(changedProperties: Map<any, any>) {
super.updated(changedProperties);
if (changedProperties.has('src')) {
this.loadConfig();
}
}
connectedCallback() {
super.connectedCallback && super.connectedCallback();
window.addEventListener('hashchange', this.onHashChange);
}
disconnectedCallback() {
super.connectedCallback && super.connectedCallback();
window.removeEventListener('hashchange', this.onHashChange);
}
private onHashChange = () => {
if (!location.hash) {
return;
}
const targetElement = this.shadowRoot!.querySelector(`${location.hash}`);
if (targetElement) {
targetElement.scrollIntoView();
}
};
private async loadConfig() {
if (this.src) {
this.config = await (await fetch(this.src)).json();
} else {
this.config = null;
}
await this.updateComplete;
this.onHashChange();
}
private selectElement(event: {detail: {element: HTMLImageElement;};}) {
this.analysisView.pick(event.detail.element);
}
render() {
const {config} = this;
if (config == null) {
return this.src ? html`Loading...` : html`No config specified`;
}
const goldens = config.renderers.map(
renderer => ({...renderer, file: `${renderer.name}-golden.png`}));
const scenarios = config!.scenarios.map((scenario) => html`
<rendering-scenario id="${scenario.name}"
.name="${scenario.name}"
.goldens="${goldens}"
.dimensions="${scenario.dimensions}"
.exclude="${scenario.exclude || []}"
@select="${this.selectElement}">
</rendering-scenario>`);
return html`
<style>
#scenarios {
max-width: 100%;
padding-bottom: 5em;
}
</style>
<div id="scenarios">
${scenarios}
</div>
<analysis-view></analysis-view>`;
}
};
customElements.define('image-comparison-app', ImageComparisonApp);
@@ -0,0 +1,175 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 '../image-accessor.js';
import './magnifying-glass.js';
import {html, LitElement} from 'lit';
import {property} from 'lit/decorators.js';
import {Dimensions, Pixel, Rect} from '../common.js';
import {ImageAccessor} from '../image-accessor.js';
export class Images4Up extends LitElement {
@property({type: Object}) topLeftImageAccessor: ImageAccessor|null = null;
@property({type: Object}) topRightImageAccessor: ImageAccessor|null = null;
@property({type: Object}) bottomLeftImageAccessor: ImageAccessor|null = null;
@property({type: Object}) bottomRightImageAccessor: ImageAccessor|null = null;
@property({type: Object}) magnifiedPixel: Pixel|null = null;
@property({type: Object}) dimensions: Dimensions = {width: 0, height: 0};
@property({type: Object})
protected containerRect: Rect = {x: 0, y: 0, width: 0, height: 0};
localCoordinateToImagePixel(localX: number, localY: number): Pixel {
const constrainedX = Math.min(
Math.max(localX - this.containerRect.x, -1),
this.containerRect.width + 1);
const constrainedY = Math.min(
Math.max(localY - this.containerRect.y, -1),
this.containerRect.height + 1);
const scaledWidth = this.containerRect.width / 2;
const scaledHeight = this.containerRect.height / 2;
const scale = this.dimensions.width / scaledWidth;
return {
x: Math.floor((constrainedX % scaledWidth) * scale),
y: Math.floor((constrainedY % scaledHeight) * scale)
};
}
protected selectPixel(pixel: Pixel) {
this.magnifiedPixel = pixel;
this.dispatchEvent(new CustomEvent('select-pixel', {detail: pixel}));
}
onPointerEvent(event: PointerEvent) {
const pixel = this.localCoordinateToImagePixel(event.x, event.y);
if (pixel.x > -1 && pixel.x < this.dimensions.width && pixel.y > -1 &&
pixel.y < this.dimensions.height) {
this.selectPixel(pixel);
} else {
this.magnifiedPixel = null;
}
}
connectedCallback() {
super.connectedCallback && super.connectedCallback();
self.addEventListener('resize', (_event) => this.updateSize());
}
updateSize() {
const rect = this.getBoundingClientRect();
const {width: containerWidth, height: containerHeight} = rect;
const fourUpWidth = 2 * this.dimensions.width;
const fourUpHeight = 2 * this.dimensions.height;
const containerAspectRatio = containerWidth / containerHeight;
const fourUpAspectRatio = fourUpWidth / fourUpHeight;
const scale = fourUpAspectRatio < containerAspectRatio ?
containerHeight / fourUpHeight :
containerWidth / fourUpWidth;
const width = scale * fourUpWidth;
const height = scale * fourUpHeight;
const x = (containerWidth - width) / 2;
const y = (containerHeight - height) / 2;
this.containerRect = {x, y, width, height};
}
async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('dimensions') && this.dimensions != null) {
this.updateSize();
this.selectPixel({
x: Math.floor(this.dimensions.width / 2),
y: Math.floor(this.dimensions.height / 2)
});
}
}
render() {
const {x, y, width, height} = this.containerRect;
return html`
<style>
:host {
display: block;
position: relative;
}
magnifying-glass {
flex: 1 1 50%;
width: 50%;
height: 50%;
}
#container {
display: flex;
cursor: crosshair;
position: absolute;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
justify-content: flex-start;
}
#magnifier-button {
position: absolute;
top: 0;
right: 0;
}
</style>
<div id="container"
@pointermove="${(event: PointerEvent) => this.onPointerEvent(event)}"
@pointerdown="${(event: PointerEvent) => this.onPointerEvent(event)}"
style="transform: translate(${x}px, ${y}px);
width: ${width}px;
height: ${height}px">
<magnifying-glass id="top-left"
.pixel="${this.magnifiedPixel}"
.imageAccessor="${this.topLeftImageAccessor}">
<slot name="top-left"></slot>
</magnifying-glass>
<magnifying-glass id="top-right"
.pixel="${this.magnifiedPixel}"
.imageAccessor="${this.topRightImageAccessor}">
<slot name="top-right"></slot>
</magnifying-glass>
<magnifying-glass id="bottom-left"
.pixel="${this.magnifiedPixel}"
.imageAccessor="${this.bottomLeftImageAccessor}">
<slot name="bottom-left"></slot>
</magnifying-glass>
<magnifying-glass id="bottom-right"
.pixel="${this.magnifiedPixel}"
.imageAccessor="${this.bottomRightImageAccessor}">
<slot name="bottom-right"></slot>
</magnifying-glass>
</div>`;
}
}
customElements.define('images-4-up', Images4Up);
@@ -0,0 +1,185 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 {html, LitElement} from 'lit';
import {property} from 'lit/decorators.js';
import {Pixel} from '../common.js';
import {ImageAccessor} from '../image-accessor.js';
const PIXEL_SIZE = 10;
export class MagnifyingGlass extends LitElement {
@property({type: Object}) imageAccessor: ImageAccessor|null = null;
@property({type: Object}) pixel: Pixel|null = null;
@property({type: String}) direction: string = 'vertical';
@property({type: Object}) position: Pixel = {x: 0, y: 0};
protected context: CanvasRenderingContext2D|null = null;
@property({type: Boolean}) protected xRay: boolean = true;
@property({type: Number}) protected reticleSize: number = 1;
protected enhance() {
if (this.pixel == null || this.imageAccessor == null) {
return;
}
this.classList.remove('hidden');
if (this.context == null) {
const canvas = this.shadowRoot!.querySelector('canvas');
if (canvas == null) {
return;
}
this.context = canvas.getContext('2d')!;
}
const {context} = this;
const rect = this.getBoundingClientRect();
const scale = rect.width / this.imageAccessor!.width;
for (let y = 0; y < 24; ++y) {
for (let x = 0; x < 24; ++x) {
context.fillStyle = this.imageAccessor!.cssColorAt(
this.pixel.x + x - 12, this.pixel.y + y - 12);
context.fillRect(
x * PIXEL_SIZE, y * PIXEL_SIZE, PIXEL_SIZE, PIXEL_SIZE);
}
}
this.reticleSize = Math.round(26 * scale);
this.position = {
x: this.pixel.x * scale - 130,
y: this.pixel.y * scale - 130
};
}
toggleXRay() {
this.xRay = !this.xRay;
}
protected hide() {
this.classList.add('hidden');
}
get glassPosition() {
const {width, height} =
this.imageAccessor != null ? this.imageAccessor : {width: 0, height: 0};
const {x, y} = this.pixel != null ? this.pixel : {x: 0, y: 0};
return this.direction === 'horizontal' ?
x < (width / 2) ? 'right' : 'left' :
y < (height / 2) ? 'bottom' : 'top';
}
render() {
this.enhance();
return html`
<style>
:host {
display: block;
position: relative;
}
#glass {
display: block;
position: absolute;
box-sizing: border-box;
top: 0;
left: 0;
width: 260px;
height: 260px;
transition: transform 0.1s;
z-index: 1;
background-color: transparent;
}
canvas {
box-sizing: border-box;
box-shadow: 0 2px 10px rgba(40, 40, 40, 0.25);
width: 100%;
height: 100%;
transition: opacity 0.3s, transform 0.5s;
opacity: 1;
}
#glass.top canvas {
transform: translate(0, -180px);
}
#glass.bottom canvas {
transform: translate(0, 180px);
}
#glass.left canvas {
transform: translate(-180px, 0);
}
#glass.right canvas {
transform: translate(180px, 0);
}
#glass:before {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
transform: translate(calc(-50% + 130px), calc(130px - var(--reticle-size) / 2));
box-sizing: border-box;
border: 1px solid black;
width: var(--reticle-size);
height: var(--reticle-size);
}
#glass:after {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
transform: translate(calc(-50% + 130px), calc(129px - var(--reticle-size) / 2));
box-sizing: border-box;
border: 1px solid white;
width: calc(var(--reticle-size) + 2px);
height: calc(var(--reticle-size) + 2px);
}
:host(.hidden) canvas {
opacity: 0;
}
</style>
<slot></slot>
<div id="glass"
class="${this.glassPosition}"
style="transform: translate(${this.position.x}px, ${
this.position.y}px); --reticle-size: ${this.reticleSize}px"
@click="${() => this.toggleXRay()}">
<canvas style="opacity: ${this.xRay ? 0 : 1};" width="240" height="240">
</div>
</canvas>
`;
}
}
customElements.define('magnifying-glass', MagnifyingGlass);
@@ -0,0 +1,153 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 {css, html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js'
import {ImageComparisonConfig, ScenarioConfig} from '../common.js';
import {ConfigReader} from '../config-reader.js';
@customElement('renderer-harness')
export class RendererConfiguration extends LitElement {
@property({type: String, attribute: 'scenario-name'})
scenarioName: string|null = null;
@property({type: String, attribute: 'config-url'})
configUrl: string|null = null;
@property({type: Boolean, attribute: 'hide-ui'}) hideUi: boolean = false;
@property({type: Object}) protected config: ImageComparisonConfig|null = null;
@property({type: Object}) protected scenario: ScenarioConfig|null = null;
connectedCallback() {
super.connectedCallback();
const {queryParameters} = this;
this.configUrl = queryParameters.config || '../../config.json';
this.scenarioName = queryParameters.scenario || null;
this.hideUi = 'hide-ui' in queryParameters || false;
}
protected get queryParameters(): {[index: string]: string} {
return self.location.search.slice(1).split('&').reduce(
(queryParameters, parameter) => {
const [key, value] = parameter.split('=');
queryParameters[key] = decodeURIComponent(value);
return queryParameters;
},
{} as {[index: string]: string});
}
async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('configUrl')) {
if (this.configUrl == null) {
this.config = null;
} else {
this.config = await (await fetch(this.configUrl)).json();
}
}
if (changedProperties.has('scenarioName') ||
changedProperties.has('configUrl')) {
if (this.scenarioName == null || this.config == null) {
this.scenario = null;
} else {
this.scenario =
(new ConfigReader(this.config)).scenarioConfig(this.scenarioName);
}
const previousScenarioName = changedProperties.get('scenarioName');
if (previousScenarioName !== this.scenarioName) {
this.dispatchEvent(new CustomEvent(
'scenario-change', {detail: {scenario: this.scenario}}));
}
if (previousScenarioName != null && this.scenarioName != null) {
const url = new URL(window.location.toString());
url.search = url.search.replace(
`scenario=${encodeURIComponent(previousScenarioName)}`,
`scenario=${encodeURIComponent(this.scenarioName)}`);
history.pushState(null, document.title, url.toString());
}
}
}
static get styles() {
return css`
:host {
display: flex;
flex-direction: column;
font-family: system-ui, sans-serif;
color: #fafafa;
}
#widgets {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background-color: #444;
padding: 0 1em;
min-height: 4em;
}
#widgets > * {
margin-right: 1em;
}
#widgets.hidden {
display: none;
}
#renderer {
max-width: 100%;
overflow: auto;
}
`;
}
render() {
const widgets = [];
if (this.config != null) {
const scenarioOptions = this.config.scenarios.map(
scenario => html`<option ?selected="${
scenario.name === this.scenarioName}">${scenario.name}</option>`);
widgets.push(html`
<select @change="${
(event: Event) => this.scenarioName =
(event.target != null ?
(event.target as HTMLSelectElement).value :
this.scenarioName)}">
${scenarioOptions}
</select>`);
}
return html`
<div id="widgets" class="${this.hideUi ? 'hidden' : ''}">
<h2><slot name="title"></slot></h2>
${widgets}
</div>
<div id="renderer" .style="${this.hideUi ? 'overflow: visible' : ''}">
<slot name="renderer"></slot>
</div>
`;
}
}
@@ -0,0 +1,262 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 {html, LitElement} from 'lit';
import {property} from 'lit/decorators.js';
import {Dimensions, GoldenConfig, ImageComparisonAnalysis, ScenarioRecord, toDecibel} from '../common.js';
const DEFAULT_DIMENSIONS: Dimensions = {
width: 0,
height: 0
};
export class RenderingScenario extends LitElement {
@property({type: String}) name: string = '';
@property({type: Object}) goldens: Array<GoldenConfig> = [];
@property({type: Object}) dimensions: Dimensions = DEFAULT_DIMENSIONS;
@property({type: Array}) exclude: Array<string> = [];
@property({type: Object}) analysis: ScenarioRecord|null = null;
get basePath() {
if (!this.name) {
return '';
}
return `./results/${this.name}`;
}
async connectedCallback() {
super.connectedCallback && super.connectedCallback();
this.shadowRoot!.addEventListener('click', (event) => {
const originatingElement = event.target as HTMLImageElement;
if (originatingElement == null || originatingElement.tagName !== 'IMG') {
return;
}
this.dispatchEvent(
new CustomEvent('select', {detail: {element: originatingElement}}));
});
}
updated(changedProperties: Map<any, any>) {
super.updated(changedProperties);
if (this.analysis == null) {
this.loadAnalysis();
}
}
private async loadAnalysis() {
const analysisPath = `${this.basePath}/analysis.json`;
this.analysis = await (await fetch(analysisPath)).json();
}
private metricTemplate(
analysisResults: ImageComparisonAnalysis, goldenName: string) {
const rmsInDecibel = toDecibel(analysisResults.rmsDistanceRatio).toFixed(2);
return html` <span>${rmsInDecibel} dB </span>
<div class="tooltip">
<span class="question-icon"> </span>
<span class="tooltiptext">
Root mean square(RMS) color distance between ${goldenName}'s golden
and model-viewer's on rendering ${this.name} in decibels.
The decibel is given by: 10 * log(RMS). More negative means a closer match.
</span>
</div>`
}
render() {
const {basePath} = this;
const {width} = this.dimensions;
const images =
this.goldens.filter(golden => !this.exclude.includes(golden.name))
.map(
(golden, index) => html`
<div class="screenshot">
<header>
<h2>${golden.description}</h2>
</header>
<div class="check"></div>
<img data-id="${this.name} ${golden.name}"
style="width:${width}px" src="${basePath}/${golden.file}">
<div class = "metrics">
${
index == 0 || this.analysis == null ?
html`<span>---</span>` :
this.metricTemplate(
this.analysis.analysisResults[index - 1],
golden.name)}
</div>
</div>`);
return html`
<style>
:host {
display: inline-flex;
flex-direction: column;
flex: 0 1 auto;
position: relative;
margin: 1em;
padding: 1em;
border-radius: 3px;
box-shadow: 0px 2px 8px rgba(100, 100, 100, 0.2);
background-color: #fff;
color: #555;
font-family: Google Sans, sans-serif;
}
h1 {
font-size: 1.5em;
margin: 0.166em 0.33em 0.33em;
}
.tooltip {
position: relative;
display: inline-block;
}
.tooltip .tooltiptext {
opacity: 0.4;
visibility: hidden;
width: 200px;
font-size: 0.8em;
background-color: black;
color: #fff;
text-align: left;
border-radius: 6px;
padding: 5px 5px;
position: absolute;
z-index: 1;
}
.tooltip:hover .tooltiptext {
visibility: visible;
}
.question-icon {
background-image: url(../../shared-assets/icons/help_outline-24px.svg);
background-size : 0.8em;
background-repeat: no-repeat;
background-position: 0% 100%;
width: 1em;
height: 1em;
display: inline-block;
}
.metrics {
text-align: center;
height: 2em;
}
.screenshot > header {
display: block;
position: absolute;
width: 100%;
bottom: 2em;
left: 0;
text-align: center;
pointer-events: none;
}
h2 {
font-size: 1em;
display: inline-block;
box-sizing: border-box;
border-radius: 3px;
padding: 0.25em 0.5em;
margin: 0;
background-color: rgba(255, 255, 255, 0.75);
pointer-events: none;
}
#screenshots {
display: flex;
flex-direction: row;
width: 100%;
height: 544px;
align-items: center;
flex-wrap: nowrap;
}
.screenshot {
box-sizing: border-box;
display: flex;
position: relative;
flex-direction: column;
object-fit: contain;
margin: 0.5em;
transition: transform 0.3s;
}
.screenshot > img {
max-width: 256px;
flex: 0 0 0;
}
.check {
display: flex;
position: absolute;
align-items: center;
justify-content: center;
box-sizing: bborder-box;
top: 0.5em;;
right: 0.5em;
width: 1em;
height: 1em;
border-radius: 2em;
border: 2px solid #fff;
background-color: #f8f8f8;
}
.selected .check {
background-color: #37474f;
}
.selected .check:after {
content: '';
display: block;
width: 0.25em;
height: 0.5em;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: translate(-0em, -0.1em) rotate(45deg);
}
.selected {
transform: translateY(-0.5em) scale(1.025);
box-shadow: 0px 6px 12px rgba(100, 100, 100, 0.2);
}
</style>
<h1><a href="#${this.name}">${this.name}</a></h1>
<div id="screenshots">
${images}
</div>`;
}
}
customElements.define('rendering-scenario', RenderingScenario);
@@ -0,0 +1,74 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 {Dimensions, ImageComparisonConfig, RendererConfig, ScenarioConfig} from './common.js';
const defaultScenario = {
lighting: '../../../shared-assets/environments/lightroom_14b.hdr',
dimensions: {width: 768, height: 768},
target: {x: 0, y: 0, z: 0},
orbit: {theta: 0, phi: 90, radius: 1},
verticalFoV: 45,
renderSkybox: false
};
export class ConfigReader {
constructor(readonly config: ImageComparisonConfig) {
}
scenarioConfig(name: string): ScenarioConfig|null {
const {scenarios} = this.config;
for (const scenario of scenarios) {
if (scenario.name === name) {
const output = Object.assign({}, defaultScenario, scenario);
// This is necessary because Object.assign is not deep
output.dimensions =
Object.assign({}, defaultScenario.dimensions, scenario.dimensions);
output.target =
Object.assign({}, defaultScenario.target, scenario.target);
output.orbit = Object.assign({}, defaultScenario.orbit, scenario.orbit);
return output;
}
}
return null;
}
rendererConfig(name: string): RendererConfig|null {
const {renderers} = this.config;
for (const renderer of renderers) {
if (renderer.name === name) {
return renderer;
}
}
return null;
}
dimensionsForScenario(name: string): Dimensions {
const {scenarios} = this.config;
for (const scenario of scenarios) {
if (scenario.name === name) {
return scenario.dimensions;
}
}
return {width: 0, height: 0};
}
}
@@ -0,0 +1,64 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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.
*/
const canvas: HTMLCanvasElement = document.createElement('canvas');
const context: CanvasRenderingContext2D = canvas.getContext('2d')!;
export class ImageAccessor {
static fromArrayBuffer(buffer: ArrayBuffer, width: number, height: number):
ImageAccessor {
return new ImageAccessor(
new ImageData(new Uint8ClampedArray(buffer), width, height));
}
static fromImageElement(image: HTMLImageElement): ImageAccessor {
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
context.drawImage(image, 0, 0);
return new ImageAccessor(
context.getImageData(0, 0, canvas.width, canvas.height));
}
toArrayBuffer(): ArrayBuffer {
const {buffer} = this.source.data;
return buffer.slice(0, buffer.byteLength) as ArrayBuffer;
}
cssColorAt(x: number, y: number): string {
if (x < 0 || y < 0 || x > (this.width - 1) || y > (this.height - 1)) {
return 'black';
}
const position = (y * this.width + x) * 4;
const array = this.source.data;
const color = `rgb(${array[position]}, ${array[position + 1]}, ${
array[position + 2]})`;
return color;
}
constructor(protected source: ImageData) {
}
get width(): number {
return this.source.width;
}
get height(): number {
return this.source.height;
}
}
@@ -0,0 +1,131 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 {CanvasesReadyMessage, ImageComparator, ImageComparisonMessage, ImagesAssignedMessage, OffscreenCanvas, ThresholdChangedMessage} from './common.js';
class ImageComparisonWorker {
protected analyzer: ImageComparator|null = null;
protected candidateCanvas: OffscreenCanvas|null = null;
protected candidateContext: CanvasRenderingContext2D|null = null;
protected goldenCanvas: OffscreenCanvas|null = null;
protected goldenContext: CanvasRenderingContext2D|null = null;
protected blackWhiteCanvas: OffscreenCanvas|null = null;
protected blackWhiteContext: CanvasRenderingContext2D|null = null;
protected deltaCanvas: OffscreenCanvas|null = null;
protected deltaContext: CanvasRenderingContext2D|null = null;
constructor() {
self.onmessage = (event: MessageEvent) => this.onGlobalMessage(event);
}
onMessage(event: MessageEvent, port: MessagePort): void {
const data = event.data as ImageComparisonMessage;
switch (data.type) {
case 'canvases-ready': {
const {candidateCanvas, goldenCanvas, blackWhiteCanvas, deltaCanvas} =
data as CanvasesReadyMessage;
this.candidateCanvas = candidateCanvas;
this.candidateContext = candidateCanvas.getContext('2d');
this.goldenCanvas = goldenCanvas;
this.goldenContext = goldenCanvas.getContext('2d');
this.blackWhiteCanvas = blackWhiteCanvas;
this.blackWhiteContext = blackWhiteCanvas.getContext('2d');
this.deltaCanvas = deltaCanvas;
this.deltaContext = deltaCanvas.getContext('2d');
break;
}
case 'images-assigned': {
const {candidateImageBuffer, goldenImageBuffer, dimensions} =
data as ImagesAssignedMessage;
if (this.candidateCanvas == null || this.goldenCanvas == null ||
this.blackWhiteCanvas == null || this.deltaCanvas == null) {
console.warn('Images assigned before canvases are available!');
}
this.candidateCanvas!.width = this.goldenCanvas!.width =
this.blackWhiteCanvas!.width = this.deltaCanvas!.width =
dimensions.width;
this.candidateCanvas!.height = this.goldenCanvas!.height =
this.blackWhiteCanvas!.height = this.deltaCanvas!.height =
dimensions.height;
const candidateArray = new Uint8ClampedArray(candidateImageBuffer);
const goldenArray = new Uint8ClampedArray(goldenImageBuffer);
const {width, height} = dimensions;
this.analyzer =
new ImageComparator(candidateArray, goldenArray, dimensions);
this.candidateContext!.putImageData(
new ImageData(candidateArray, width, height), 0, 0);
this.goldenContext!.putImageData(
new ImageData(goldenArray, width, height), 0, 0);
break;
}
case 'threshold-changed': {
const {threshold} = data as ThresholdChangedMessage;
const {analyzer} = this;
if (analyzer == null) {
console.warn(`Analyzer not created!`);
return;
}
const {width, height} = this.analyzer!.dimensions;
const result = analyzer.generateVisuals(threshold);
this.blackWhiteContext!.putImageData(
new ImageData(
new Uint8ClampedArray(result.imageBuffers.blackWhite!),
width,
height),
0,
0);
this.deltaContext!.putImageData(
new ImageData(
new Uint8ClampedArray(result.imageBuffers.delta!),
width,
height),
0,
0);
port.postMessage({type: 'analysis-completed', result});
break;
}
default:
break;
}
}
onGlobalMessage(event: MessageEvent): void {
event.ports.forEach(
port => port.onmessage = (event) => this.onMessage(event, port));
}
}
(self as any).imageComparisonWorker = new ImageComparisonWorker();
@@ -0,0 +1,15 @@
ISC License
Copyright (c) 2018, Mapbox
Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
@@ -0,0 +1,16 @@
name: "pixelmatch"
description:
"JavaScript image comparison library"
third_party {
url {
type: HOMEPAGE
value: "https://github.com/mapbox/pixelmatch"
}
url {
type: GIT
value: "https://github.com/mapbox/pixelmatch.git"
}
version: "4.0.2"
last_upgrade_date { year: 2018 month: 12 day: 18 }
}
@@ -0,0 +1,58 @@
/* @license ISC
* @see LICENSE
*/
// NOTE(cdata): This is an adapted subset of the original pixelmatch library.
// The top-level API of the original library has been omitted, as we only make
// use of the lower-level features that aren't actually exported by the upstream
// module.
// calculate color difference according to the paper "Measuring perceived color
// difference using YIQ NTSC transmission color space in mobile applications" by
// Y. Kotsarenko and F. Ramos
export function colorDelta(
img1: {[index: number]: number},
img2: {[index: number]: number},
k: number,
m: number,
yOnly: boolean = false): number {
var a1 = img1[k + 3] / 255, a2 = img2[m + 3] / 255,
r1 = blend(img1[k + 0], a1), g1 = blend(img1[k + 1], a1),
b1 = blend(img1[k + 2], a1),
r2 = blend(img2[m + 0], a2), g2 = blend(img2[m + 1], a2),
b2 = blend(img2[m + 2], a2),
y = rgb2y(r1, g1, b1) - rgb2y(r2, g2, b2);
if (yOnly)
return y; // brightness difference only
var i = rgb2i(r1, g1, b1) - rgb2i(r2, g2, b2),
q = rgb2q(r1, g1, b1) - rgb2q(r2, g2, b2);
return 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q;
}
function rgb2y(r: number, g: number, b: number): number {
return r * 0.29889531 + g * 0.58662247 + b * 0.11448223;
}
function rgb2i(r: number, g: number, b: number): number {
return r * 0.59597799 - g * 0.27417610 - b * 0.32180189;
}
function rgb2q(r: number, g: number, b: number): number {
return r * 0.21147017 - g * 0.52261711 + b * 0.31114694;
}
// blend semi-transparent color with white
function blend(c: number, a: number): number {
return 255 + (c - 255) * a;
}
export function drawPixel(
output: Uint8Array, pos: number, r: number, g: number, b: number) {
output[pos + 0] = r;
output[pos + 1] = g;
output[pos + 2] = b;
output[pos + 3] = 255;
}
@@ -0,0 +1,141 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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.
*/
const ALERT_THRESHOLD = 0.1;
import {resolve, join} from 'path';
const [candidateResultsDirectory, goldenResultsDirectory] =
process.argv.slice(2);
const warn = (message: string) => console.warn(`🚨 ${message}`);
const exit = () => {
console.log(`📋 Fidelity result comparison concluded`);
process.exit(0);
};
console.log(`🛂 Comparing fidelity to previously recorded results at ${
goldenResultsDirectory}`);
const candidateConfig =
require(resolve(join(candidateResultsDirectory, 'config.json')));
const goldenConfig =
require(resolve(join(goldenResultsDirectory, 'config.json')));
const {scenarios: candidateScenarios, analysisThresholds: candidateThresholds} =
candidateConfig;
const {scenarios: goldenScenarios, analysisThresholds: goldenThresholds} =
goldenConfig;
const goldenScenarioMap = new Map();
const remainingGoldenThresholds = goldenThresholds.slice();
for (let i = 0; i < candidateThresholds.length; ++i) {
const candidateThreshold = candidateThresholds[i];
if (remainingGoldenThresholds.length === 0) {
warn(`${goldenResultsDirectory} tests ${
candidateThresholds.length -
goldenThresholds.length} fewer thresholds than ${
candidateResultsDirectory}`);
exit();
}
const goldenThreshold = remainingGoldenThresholds.shift();
if (candidateThreshold !== goldenThreshold) {
warn(`Candidate uses threshold ${
candidateThreshold} where golden uses threshold ${goldenThreshold}`);
exit();
}
}
for (const goldenScenario of goldenScenarios) {
goldenScenarioMap.set(goldenScenario.name, goldenScenario);
}
for (const candidateScenario of candidateScenarios) {
const {name} = candidateScenario;
if (!goldenScenarioMap.has(name)) {
warn(`${goldenResultsDirectory} does not include scenario "${
name}" found in ${candidateResultsDirectory}`);
continue;
}
const goldenScenario = goldenScenarioMap.get(name);
const candidateAnalysis =
require(resolve(join(candidateResultsDirectory, name, 'analysis.json')));
const goldenAnalysis =
require(resolve(join(goldenResultsDirectory, name, 'analysis.json')));
const goldenGoldenMap = new Map();
for (const goldenGolden of goldenScenario.goldens) {
goldenGoldenMap.set(goldenGolden.name, goldenGolden);
}
for (let i = 0; i < candidateScenario.goldens.length; ++i) {
const candidateGolden = candidateScenario.goldens[i];
if (!goldenGoldenMap.has(candidateGolden.name)) {
warn(`${goldenResultsDirectory} does not include an analysis of "${
candidateGolden.name}" for scenario "${name}" found in ${
candidateResultsDirectory}`);
continue;
}
const candidateResults = candidateAnalysis.analysisResults[i];
const goldenResults = goldenAnalysis.analysisResults[i];
for (let j = 0; j < candidateThresholds.length; ++j) {
const threshold = candidateThresholds[j];
const candidateThresholdResult = candidateResults[j];
const goldenThresholdResult = goldenResults[j];
for (const key in candidateThresholdResult) {
if (!(key in goldenThresholdResult)) {
warn(`Golden analysis of "${candidateGolden.name}" in scenario "${
name}" is missing metric "${key}"`);
continue;
}
const delta =
goldenThresholdResult[key] - candidateThresholdResult[key];
const comparisonDescription =
`<model-viewer> <-> ${candidateGolden.name}`;
const comparisonConstraints =
`"${name}/${key}" @ threshold ${threshold}`;
const percentage = `${(delta * 100).toFixed(2)}%`;
if (delta > ALERT_THRESHOLD) {
warn(`${comparisonDescription} ${
comparisonConstraints} decreased by ${percentage}!`);
} else if (Math.abs(delta) > 0) {
const changeDescription = delta > 0 ? 'decreased' : 'increased';
console.log(`🔍 ${comparisonDescription} ${comparisonConstraints} ${
changeDescription} by ${percentage}`);
}
}
}
goldenGoldenMap.delete(candidateGolden.name);
}
goldenScenarioMap.delete(name);
}
exit();
@@ -0,0 +1,299 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 {spawn} from 'child_process';
import {promises as fs} from 'fs';
import HTTPServer from 'http-server';
import module from 'module';
import {dirname, join, resolve} from 'path';
import yargs from 'yargs';
import {hideBin} from 'yargs/helpers';
import {ArtifactCreator} from '../artifact-creator.js';
import {ImageComparisonConfig} from '../common.js';
import {ConfigReader} from '../config-reader.js';
type CommandLineArgs =
{
config: string,
renderer: string[],
scenario: string[],
port: number,
missingOnly: boolean,
dryRun: boolean,
quiet: boolean
}
async function main() {
const argv =
await yargs(hideBin(process.argv))
.options({
'config': {
type: 'string',
alias: 'c',
description: 'Path to configuration json',
demandOption:
true, // Makes it mandatory. Adjust as per your needs.
},
'renderer': {
type: 'array',
alias: 'r',
description: 'Limit to specific renderers',
demandOption:
false, // Makes it mandatory. Adjust as per your needs.
},
'scenario': {
type: 'array',
alias: 's',
description: 'Limit to specific scenarios',
demandOption:
false, // Makes it mandatory. Adjust as per your needs.
},
'port': {
type: 'number',
alias: 'p',
description: 'Port for web server',
default: 9040,
},
'missing-only': {
type: 'boolean',
alias: 'm',
description: 'Only render if an output image is missing',
default: false,
},
'dry-run': {
type: 'boolean',
alias: 'd',
description:
'Lists which images would be rendered but doesn\'t render',
default: false,
},
'quiet': {
type: 'boolean',
alias: 'q',
description: 'Hide the puppeteer controlled browser',
default: false,
},
})
.help()
.alias('help', 'h')
.argv;
const args: CommandLineArgs = {
config: argv.config as string,
renderer: (argv.renderer || []) as string[],
scenario: (argv.scenario || []) as string[],
port: argv.port as number,
missingOnly: argv['missing-only'],
dryRun: argv['dry-run'],
quiet: argv.quiet,
};
const require = module.createRequire(import.meta.url);
const warn = (message: string) => console.warn(`🚨 ${message}`);
const exit = (code = 0) => {
console.log(`📋 Screenshot updates concluded`);
process.exit(code);
};
const configPath = resolve(args.config);
const rootDirectory = resolve(dirname(configPath));
const config = require(configPath);
const {renderers} = config;
const goldensDirectory = join(rootDirectory, 'goldens');
const renderersDirectory = join(rootDirectory, 'renderers');
let scenarioWhitelist: Set<string>|null = null;
let rendererWhitelist: Set<string>|null = null;
const rendererList = new Set(config.renderers.map((renderer: any) => {
return renderer.name;
}));
// user has specify either scenarios or renderers
if (args.renderer.length > 0) {
rendererWhitelist = new Set();
args.renderer.forEach((rendererName: string) => {
if (rendererList.has(rendererName)) {
rendererWhitelist!.add(rendererName);
} else {
warn(`Requested renderer "${rendererName}" not found in config`);
}
});
}
if (args.scenario.length > 0) {
scenarioWhitelist = new Set();
args.scenario.forEach((scenarioName: string) => {
const scenarioNameLower = scenarioName.toLowerCase();
let numMatches = 0;
config.scenarios.forEach((scenario: any) => {
if (scenario.name.toLowerCase().indexOf(scenarioNameLower) >= 0) {
if (!scenarioWhitelist!.has(scenario.name)) {
scenarioWhitelist!.add(scenario.name);
}
numMatches++;
}
});
if (numMatches) {
warn(`Requested scenario "${
scenarioName}" does not match any names found in config`);
}
});
}
const run = async (
command: string,
args: Array<string>,
environmentVariables = {},
workingDirectory = process.cwd()) =>
new Promise<void>((resolve, reject) => {
const childProcess = spawn(command, args, {
cwd: workingDirectory,
env: {...process.env, ...environmentVariables},
stdio: ['ignore', 'inherit', 'inherit']
});
childProcess.once('error', (error: any) => {
warn(error);
});
childProcess.once('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error('Command failed'));
}
});
});
const updateScreenshots = async (config: ImageComparisonConfig) => {
const {scenarios} = config;
console.log(`🆙 Updating screenshots`);
try {
await fs.mkdir(goldensDirectory);
} catch (error) {
// Ignored...
}
const configReader = new ConfigReader(config);
const server = HTTPServer.createServer({root: '../../', cache: -1});
server.listen(args.port);
for (const renderer of renderers) {
const {
name: rendererName,
description: rendererDescription,
scripts: scripts
} = renderer;
const screenshotCreator = new ArtifactCreator(
config,
rootDirectory,
`http://localhost:${
args.port}/packages/render-fidelity-tools/test/renderers/${
rendererName}/`);
for (const scenarioBase of scenarios) {
const scenarioName = scenarioBase.name;
const scenario = configReader.scenarioConfig(scenarioName);
const {model, lighting, dimensions, exclude} = scenario!;
const scenarioGoldensDirectory = join(goldensDirectory, scenarioName);
const {width, height} = dimensions;
try {
await fs.mkdir(scenarioGoldensDirectory);
} catch (error) {
// Ignored...
}
if (exclude != null && exclude.includes(rendererName) ||
rendererWhitelist != null && !rendererWhitelist.has(rendererName)) {
continue;
}
const goldenFilename = `${rendererName}-golden.png`;
const rendererDirectory = join(renderersDirectory, rendererName);
const goldenPath = join(scenarioGoldensDirectory, goldenFilename);
if (scenarioWhitelist != null && !scenarioWhitelist.has(scenarioName)) {
console.log(`⏭ Skipping ${scenarioName}...`);
continue;
}
if (args.missingOnly) {
try {
await fs.access(goldenPath);
console.log(`⏭ Skipping ${scenarioName} as render exists...`);
continue;
} catch (error) {
// ignored
}
}
if (scripts != null && scripts.setup != null &&
!lighting.includes('spot1Lux')) {
const setup = join(rendererDirectory, scripts.setup);
console.log(`🚧 Running setup script: ${scripts.setup}`);
await run(
setup,
[],
{
SCENARIO_NAME: scenarioName,
MODEL: resolve(rendererDirectory, model),
LIGHTING: resolve(rendererDirectory, lighting),
RENDER_WIDTH: width,
RENDER_HEIGHT: height
},
rendererDirectory);
}
if (args.dryRun) {
process.stdout.write(
rendererName + `: Rendering ` + scenarioName +
'... -- skipping, dry-run');
continue;
}
try {
await screenshotCreator.captureScreenshot(
rendererName,
scenarioName,
dimensions,
goldenPath,
-1,
args.quiet);
} catch (error) {
throw new Error(`Failed to update ${
rendererDescription} screenshot: ${error.message}`);
}
}
screenshotCreator.close();
}
server.close();
};
updateScreenshots(config).then(() => exit(0)).catch((error) => {
console.error(error);
exit(1);
});
}
main();
@@ -0,0 +1,224 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* 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 HTTPServer from 'http-server';
import module from 'module';
import {dirname, join, resolve} from 'path';
import {rimraf} from 'rimraf';
import yargs from 'yargs';
import {hideBin} from 'yargs/helpers';
type CommandLineArgs = {
config: string; renderer: string; scenario: string[]; port: number;
dryRun: boolean;
quiet: boolean;
}
const require = module.createRequire(import.meta.url);
// actions/core can only be imported using commonJS's require here
const core = require('@actions/core');
import {ArtifactCreator} from '../artifact-creator.js';
import {FIDELITY_TEST_THRESHOLD, toDecibel} from '../common.js';
async function main() {
const argv =
await yargs(hideBin(process.argv))
.options({
'config': {
type: 'string',
alias: 'c',
description: 'Path to configuration json',
demandOption:
true, // Makes it mandatory. Adjust as per your needs.
},
'renderer': {
type: 'string',
alias: 'r',
description: 'Name of web-based renderer to test',
default: 'model-viewer',
choices: [
'filament',
'babylon',
'gltf-sample-viewer',
'three-gpu-renderer',
'model-viewer'
]
},
'scenario': {
type: 'array',
alias: 's',
description: 'Limit to specific scenarios',
demandOption:
false, // Makes it mandatory. Adjust as per your needs.
},
'port': {
type: 'number',
alias: 'p',
description: 'Port for web server',
default: 9030,
},
'dry-run': {
type: 'boolean',
alias: 'd',
description: 'Checks that all comparison images exist',
default: false,
},
'quiet': {
type: 'boolean',
alias: 'q',
description: 'Hide the puppeteer controlled browser',
default: false,
},
})
.help()
.alias('help', 'h')
.argv;
const args: CommandLineArgs = {
config: argv.config as string,
renderer: argv.renderer as string,
scenario: (argv.scenario || []) as string[],
port: argv.port as number,
dryRun: argv['dry-run'],
quiet: argv.quiet,
};
const configPath = resolve(args.config);
const rootDirectory = resolve(dirname(configPath));
const config = require(configPath);
const outputDirectory = join(rootDirectory, 'results');
const screenshotCreator = new ArtifactCreator(
config,
rootDirectory,
`http://localhost:${
args.port}/packages/render-fidelity-tools/test/renderers/${
args.renderer}/`);
const server = HTTPServer.createServer({root: '../../', cache: -1});
server.listen(args.port);
try {
rimraf.sync(outputDirectory);
} catch (error) {
console.warn(error);
}
let scenarioWhitelist: Set<string>|null = null;
// user has specified scenarios to test
if (args.scenario.length > 0) {
scenarioWhitelist = new Set();
args.scenario.forEach((scenarioName: string) => {
const scenarioNameLower = scenarioName.toLowerCase();
let numMatches = 0;
config.scenarios.forEach((scenario: any) => {
if (scenario.name.toLowerCase().indexOf(scenarioNameLower) >= 0) {
if (!scenarioWhitelist!.has(scenario.name)) {
scenarioWhitelist!.add(scenario.name);
}
numMatches++;
}
});
if (numMatches) {
console.warn(`Requested scenario "${
scenarioName}" does not match any names found in config`);
}
});
}
try {
await screenshotCreator.fidelityTest(
scenarioWhitelist, args.renderer, args.dryRun, args.quiet);
console.log(`✅ Results recorded to ${outputDirectory}`);
const fidelityRegressionPath =
join(outputDirectory, 'fidelityRegressionResults.json');
const {
results: fidelityRegressionResults,
errors: fidelityRegressionErrors,
warnings: fidelityRegressionWarnings
} = require(fidelityRegressionPath);
const fidelityRegressionPassedCount = fidelityRegressionResults.length;
const fidelityRegressionErrorCount = fidelityRegressionErrors.length;
const fidelityRegressionWarningCount = fidelityRegressionWarnings.length;
const scenarioCount = fidelityRegressionPassedCount +
fidelityRegressionErrorCount + fidelityRegressionWarningCount
console.log(`\nFidelity regression test on ${
scenarioCount} scenarios finished. (Uses ${
FIDELITY_TEST_THRESHOLD} dB as threshold)`);
console.log(`Passed ${fidelityRegressionPassedCount} scenarios ✅`);
if (fidelityRegressionErrorCount > 0) {
console.log(`Failed ${fidelityRegressionErrorCount} scenarios ❌`);
} else {
let maxError = -Infinity;
for (const result of fidelityRegressionResults) {
maxError = Math.max(maxError, toDecibel(result.rmsDistanceRatio));
}
console.log('Worst scenario RMS:', maxError, 'dB');
}
if (fidelityRegressionWarningCount > 0) {
console.log(`Warnings on ${fidelityRegressionWarningCount} scenarios❗️`);
console.log('\n🔍 Logging warning scenarios: ');
for (const warning of fidelityRegressionWarnings) {
console.log(warning);
}
core.warning(
'❗️Fidelity test detected some warnings! Please try to fix them');
}
if (fidelityRegressionErrorCount > 0) {
console.log('\n🔍 Logging failed scenarios: ');
for (const error of fidelityRegressionErrors) {
console.log(error);
}
}
const compareRendererResultPath = join(outputDirectory, 'config.json');
const {scenarios: comparedRenderResult, errors: compareRendererErrors} =
require(compareRendererResultPath);
const compareRendererPassCount = comparedRenderResult.length;
const compareRendererErrorCount = compareRendererErrors.length;
if (compareRendererErrorCount > 0) {
console.log(
`\nCompare Renderers on ${scenarioCount} scenarios finished. ${
compareRendererPassCount} scenarios passed ✅, ${
compareRendererErrorCount} scenarios failed ❌`);
console.log('\n🔍 Logging failed scenarios: ');
for (const error of compareRendererErrors) {
console.log(error);
}
}
if (fidelityRegressionErrorCount > 0 || compareRendererErrorCount > 0) {
throw new Error(
' ❌ Fidelity test failed! Please fix the errors listed above before merging this pr!');
}
} catch (error: any) {
core.setFailed(error.message);
} finally {
server.close();
screenshotCreator.close();
}
}
main();