Advance M8-M11 parity workflows
This commit is contained in:
218
web/protocol/render-image-comparison.ts
Normal file
218
web/protocol/render-image-comparison.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user