squash commits
This commit is contained in:
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();
|
||||
Reference in new issue
Block a user