219 lines
8.9 KiB
TypeScript
219 lines
8.9 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
|
|
export const RENDER_IMAGE_COMPARISON_SCHEMA_VERSION = 1 as const;
|
|
export const RENDER_REFERENCE_MISMATCH_CODE = "RENDER_REFERENCE_MISMATCH" as const satisfies ErrorCode;
|
|
export const MAX_RENDER_COMPARISON_DIMENSION = 4_096;
|
|
export const MAX_RENDER_COMPARISON_PIXELS = 4_194_304;
|
|
|
|
export interface RenderImageComparisonThresholdsIR {
|
|
maxMeanAbsoluteError: number;
|
|
maxRootMeanSquaredError: number;
|
|
maxP95ChannelError: number;
|
|
maxBadPixelRatio: number;
|
|
badPixelChannelError: number;
|
|
foregroundDeltaFromReferenceBackground: number;
|
|
minForegroundIntersectionOverUnion: number;
|
|
maxAlphaCoverageDeltaRatio: number;
|
|
}
|
|
|
|
export interface RenderImageComparisonCheckIR {
|
|
metric: "MEAN_ABSOLUTE_ERROR" | "ROOT_MEAN_SQUARED_ERROR" | "P95_CHANNEL_ERROR" |
|
|
"BAD_PIXEL_RATIO" | "FOREGROUND_INTERSECTION_OVER_UNION" | "ALPHA_COVERAGE_DELTA_RATIO";
|
|
actual: number;
|
|
threshold: number;
|
|
comparison: "LTE" | "GTE";
|
|
passed: boolean;
|
|
}
|
|
|
|
export interface RenderImageComparisonIR {
|
|
schemaVersion: typeof RENDER_IMAGE_COMPARISON_SCHEMA_VERSION;
|
|
colorSpace: "SRGB8";
|
|
alphaMode: "STRAIGHT";
|
|
status: "READY" | "BLOCKED";
|
|
width: number;
|
|
height: number;
|
|
pixelCount: number;
|
|
comparedRGBChannels: number;
|
|
meanAbsoluteError: number;
|
|
rootMeanSquaredError: number;
|
|
p95ChannelError: number;
|
|
maxChannelError: number;
|
|
badPixelCount: number;
|
|
badPixelRatio: number;
|
|
referenceBackground: [number, number, number];
|
|
referenceForegroundPixels: number;
|
|
actualForegroundPixels: number;
|
|
foregroundIntersectionPixels: number;
|
|
foregroundUnionPixels: number;
|
|
foregroundIntersectionOverUnion: number;
|
|
referenceAlphaPixels: number;
|
|
actualAlphaPixels: number;
|
|
alphaCoverageDeltaRatio: number;
|
|
thresholds: RenderImageComparisonThresholdsIR;
|
|
checks: RenderImageComparisonCheckIR[];
|
|
errorCode: typeof RENDER_REFERENCE_MISMATCH_CODE | null;
|
|
}
|
|
|
|
function finiteRange(value: number, minimum: number, maximum: number, label: string): number {
|
|
if (!Number.isFinite(value) || value < minimum || value > maximum) {
|
|
throw new Error(`INVALID_ARGUMENT: ${label} is outside ${minimum}..${maximum}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateThresholds(value: RenderImageComparisonThresholdsIR): RenderImageComparisonThresholdsIR {
|
|
return {
|
|
maxMeanAbsoluteError: finiteRange(value.maxMeanAbsoluteError, 0, 255, "maxMeanAbsoluteError"),
|
|
maxRootMeanSquaredError: finiteRange(value.maxRootMeanSquaredError, 0, 255, "maxRootMeanSquaredError"),
|
|
maxP95ChannelError: finiteRange(value.maxP95ChannelError, 0, 255, "maxP95ChannelError"),
|
|
maxBadPixelRatio: finiteRange(value.maxBadPixelRatio, 0, 1, "maxBadPixelRatio"),
|
|
badPixelChannelError: finiteRange(value.badPixelChannelError, 0, 255, "badPixelChannelError"),
|
|
foregroundDeltaFromReferenceBackground: finiteRange(
|
|
value.foregroundDeltaFromReferenceBackground,
|
|
1,
|
|
255,
|
|
"foregroundDeltaFromReferenceBackground",
|
|
),
|
|
minForegroundIntersectionOverUnion: finiteRange(
|
|
value.minForegroundIntersectionOverUnion,
|
|
0,
|
|
1,
|
|
"minForegroundIntersectionOverUnion",
|
|
),
|
|
maxAlphaCoverageDeltaRatio: finiteRange(value.maxAlphaCoverageDeltaRatio, 0, 1, "maxAlphaCoverageDeltaRatio"),
|
|
};
|
|
}
|
|
|
|
function median(values: readonly number[]): number {
|
|
const sorted = [...values].sort((left, right) => left - right);
|
|
return sorted[Math.floor(sorted.length / 2)];
|
|
}
|
|
|
|
function referenceBackground(reference: Uint8Array, width: number, height: number): [number, number, number] {
|
|
const cornerPixels = [0, width - 1, (height - 1) * width, height * width - 1];
|
|
return [0, 1, 2].map((channel) => median(cornerPixels.map((pixel) => reference[pixel * 4 + channel]))) as [number, number, number];
|
|
}
|
|
|
|
function isForeground(bytes: Uint8Array, offset: number, background: readonly number[], threshold: number): boolean {
|
|
return Math.max(
|
|
Math.abs(bytes[offset] - background[0]),
|
|
Math.abs(bytes[offset + 1] - background[1]),
|
|
Math.abs(bytes[offset + 2] - background[2]),
|
|
) >= threshold;
|
|
}
|
|
|
|
function check(
|
|
metric: RenderImageComparisonCheckIR["metric"],
|
|
actual: number,
|
|
threshold: number,
|
|
comparison: RenderImageComparisonCheckIR["comparison"],
|
|
): RenderImageComparisonCheckIR {
|
|
return { metric, actual, threshold, comparison, passed: comparison === "LTE" ? actual <= threshold : actual >= threshold };
|
|
}
|
|
|
|
/** Compares equal-size display-referred sRGB8 frames and reports every release-gate metric. */
|
|
export function compareRenderImages(
|
|
reference: Uint8Array,
|
|
actual: Uint8Array,
|
|
width: number,
|
|
height: number,
|
|
thresholdValue: RenderImageComparisonThresholdsIR,
|
|
): RenderImageComparisonIR {
|
|
if (
|
|
!(reference instanceof Uint8Array) || !(actual instanceof Uint8Array) ||
|
|
!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 ||
|
|
width > MAX_RENDER_COMPARISON_DIMENSION || height > MAX_RENDER_COMPARISON_DIMENSION ||
|
|
width * height > MAX_RENDER_COMPARISON_PIXELS ||
|
|
reference.byteLength !== width * height * 4 || actual.byteLength !== reference.byteLength
|
|
) {
|
|
throw new Error("INVALID_ARGUMENT: render reference and actual must be equal, bounded RGBA8 frames");
|
|
}
|
|
const thresholds = validateThresholds(thresholdValue);
|
|
const pixelCount = width * height;
|
|
const channelHistogram = new Uint32Array(256);
|
|
const background = referenceBackground(reference, width, height);
|
|
let absoluteTotal = 0;
|
|
let squaredTotal = 0;
|
|
let maxChannelError = 0;
|
|
let badPixelCount = 0;
|
|
let referenceForegroundPixels = 0;
|
|
let actualForegroundPixels = 0;
|
|
let foregroundIntersectionPixels = 0;
|
|
let foregroundUnionPixels = 0;
|
|
let referenceAlphaPixels = 0;
|
|
let actualAlphaPixels = 0;
|
|
|
|
for (let pixel = 0; pixel < pixelCount; pixel++) {
|
|
const offset = pixel * 4;
|
|
let pixelMaximum = 0;
|
|
for (let channel = 0; channel < 3; channel++) {
|
|
const difference = Math.abs(reference[offset + channel] - actual[offset + channel]);
|
|
absoluteTotal += difference;
|
|
squaredTotal += difference * difference;
|
|
pixelMaximum = Math.max(pixelMaximum, difference);
|
|
maxChannelError = Math.max(maxChannelError, difference);
|
|
channelHistogram[difference]++;
|
|
}
|
|
if (pixelMaximum > thresholds.badPixelChannelError) badPixelCount++;
|
|
const referenceForeground = isForeground(reference, offset, background, thresholds.foregroundDeltaFromReferenceBackground);
|
|
const actualForeground = isForeground(actual, offset, background, thresholds.foregroundDeltaFromReferenceBackground);
|
|
if (referenceForeground) referenceForegroundPixels++;
|
|
if (actualForeground) actualForegroundPixels++;
|
|
if (referenceForeground && actualForeground) foregroundIntersectionPixels++;
|
|
if (referenceForeground || actualForeground) foregroundUnionPixels++;
|
|
if (reference[offset + 3] >= 128) referenceAlphaPixels++;
|
|
if (actual[offset + 3] >= 128) actualAlphaPixels++;
|
|
}
|
|
|
|
const comparedRGBChannels = pixelCount * 3;
|
|
const meanAbsoluteError = absoluteTotal / comparedRGBChannels;
|
|
const rootMeanSquaredError = Math.sqrt(squaredTotal / comparedRGBChannels);
|
|
const percentileTarget = Math.ceil(comparedRGBChannels * 0.95);
|
|
let percentileCount = 0;
|
|
let p95ChannelError = 0;
|
|
for (; p95ChannelError < channelHistogram.length; p95ChannelError++) {
|
|
percentileCount += channelHistogram[p95ChannelError];
|
|
if (percentileCount >= percentileTarget) break;
|
|
}
|
|
const badPixelRatio = badPixelCount / pixelCount;
|
|
const foregroundIntersectionOverUnion = foregroundUnionPixels === 0 ? 1 : foregroundIntersectionPixels / foregroundUnionPixels;
|
|
const alphaCoverageDeltaRatio = Math.abs(referenceAlphaPixels - actualAlphaPixels) / pixelCount;
|
|
const checks = [
|
|
check("MEAN_ABSOLUTE_ERROR", meanAbsoluteError, thresholds.maxMeanAbsoluteError, "LTE"),
|
|
check("ROOT_MEAN_SQUARED_ERROR", rootMeanSquaredError, thresholds.maxRootMeanSquaredError, "LTE"),
|
|
check("P95_CHANNEL_ERROR", p95ChannelError, thresholds.maxP95ChannelError, "LTE"),
|
|
check("BAD_PIXEL_RATIO", badPixelRatio, thresholds.maxBadPixelRatio, "LTE"),
|
|
check("FOREGROUND_INTERSECTION_OVER_UNION", foregroundIntersectionOverUnion, thresholds.minForegroundIntersectionOverUnion, "GTE"),
|
|
check("ALPHA_COVERAGE_DELTA_RATIO", alphaCoverageDeltaRatio, thresholds.maxAlphaCoverageDeltaRatio, "LTE"),
|
|
];
|
|
const matches = checks.every((item) => item.passed);
|
|
return {
|
|
schemaVersion: RENDER_IMAGE_COMPARISON_SCHEMA_VERSION,
|
|
colorSpace: "SRGB8",
|
|
alphaMode: "STRAIGHT",
|
|
status: matches ? "READY" : "BLOCKED",
|
|
width,
|
|
height,
|
|
pixelCount,
|
|
comparedRGBChannels,
|
|
meanAbsoluteError,
|
|
rootMeanSquaredError,
|
|
p95ChannelError,
|
|
maxChannelError,
|
|
badPixelCount,
|
|
badPixelRatio,
|
|
referenceBackground: background,
|
|
referenceForegroundPixels,
|
|
actualForegroundPixels,
|
|
foregroundIntersectionPixels,
|
|
foregroundUnionPixels,
|
|
foregroundIntersectionOverUnion,
|
|
referenceAlphaPixels,
|
|
actualAlphaPixels,
|
|
alphaCoverageDeltaRatio,
|
|
thresholds,
|
|
checks,
|
|
errorCode: matches ? null : RENDER_REFERENCE_MISMATCH_CODE,
|
|
};
|
|
}
|