Files
workinf_Blender_Wasm/web/protocol/render-image-comparison.ts
mes123456 0fe8d2bb56
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

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,
};
}