test(image-pipeline): add cpu webgl parity coverage
This commit is contained in:
57
tests/image-pipeline/parity/cpu-webgl-parity.test.ts
Normal file
57
tests/image-pipeline/parity/cpu-webgl-parity.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createParityPipelines,
|
||||
evaluateCpuWebglParity,
|
||||
installParityWebglContextMock,
|
||||
parityTolerances,
|
||||
restoreParityWebglContextMock,
|
||||
} from "@/tests/image-pipeline/parity/fixtures";
|
||||
|
||||
describe("cpu vs webgl parity", () => {
|
||||
afterEach(() => {
|
||||
restoreParityWebglContextMock();
|
||||
});
|
||||
|
||||
it("keeps curves-only pipeline within parity tolerance", () => {
|
||||
const pipelines = createParityPipelines();
|
||||
installParityWebglContextMock();
|
||||
|
||||
const metrics = evaluateCpuWebglParity(pipelines.curvesOnly);
|
||||
|
||||
expect(metrics.maxChannelDelta).toBeLessThanOrEqual(parityTolerances.curvesOnly.maxChannelDelta);
|
||||
expect(metrics.histogramSimilarity).toBeGreaterThanOrEqual(
|
||||
parityTolerances.curvesOnly.histogramSimilarity,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps color-adjust-only pipeline within parity tolerance", () => {
|
||||
const pipelines = createParityPipelines();
|
||||
installParityWebglContextMock();
|
||||
|
||||
const metrics = evaluateCpuWebglParity(pipelines.colorAdjustOnly);
|
||||
|
||||
expect(metrics.maxChannelDelta).toBeLessThanOrEqual(
|
||||
parityTolerances.colorAdjustOnly.maxChannelDelta,
|
||||
);
|
||||
expect(metrics.histogramSimilarity).toBeGreaterThanOrEqual(
|
||||
parityTolerances.colorAdjustOnly.histogramSimilarity,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps curves + color-adjust chain within parity tolerance", () => {
|
||||
const pipelines = createParityPipelines();
|
||||
installParityWebglContextMock();
|
||||
|
||||
const metrics = evaluateCpuWebglParity(pipelines.curvesPlusColorAdjust);
|
||||
|
||||
expect(metrics.maxChannelDelta).toBeLessThanOrEqual(
|
||||
parityTolerances.curvesPlusColorAdjust.maxChannelDelta,
|
||||
);
|
||||
expect(metrics.histogramSimilarity).toBeGreaterThanOrEqual(
|
||||
parityTolerances.curvesPlusColorAdjust.histogramSimilarity,
|
||||
);
|
||||
});
|
||||
});
|
||||
562
tests/image-pipeline/parity/fixtures.ts
Normal file
562
tests/image-pipeline/parity/fixtures.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
import { createWebglPreviewBackend } from "@/lib/image-pipeline/backend/webgl/webgl-backend";
|
||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
import { applyPipelineStep } from "@/lib/image-pipeline/render-core";
|
||||
import { vi } from "vitest";
|
||||
|
||||
type ParityPipelineKey = "curvesOnly" | "colorAdjustOnly" | "curvesPlusColorAdjust";
|
||||
|
||||
type HistogramBundle = {
|
||||
red: number[];
|
||||
green: number[];
|
||||
blue: number[];
|
||||
rgb: number[];
|
||||
};
|
||||
|
||||
export type ParityMetrics = {
|
||||
maxChannelDelta: number;
|
||||
histogramSimilarity: number;
|
||||
};
|
||||
|
||||
type ParityPipeline = {
|
||||
key: ParityPipelineKey;
|
||||
steps: PipelineStep[];
|
||||
};
|
||||
|
||||
type ParityTolerance = {
|
||||
maxChannelDelta: number;
|
||||
histogramSimilarity: number;
|
||||
};
|
||||
|
||||
const FIXTURE_WIDTH = 8;
|
||||
const FIXTURE_HEIGHT = 8;
|
||||
|
||||
let contextSpy: { mockRestore: () => void } | null = null;
|
||||
|
||||
export const parityTolerances: Record<ParityPipelineKey, ParityTolerance> = {
|
||||
curvesOnly: {
|
||||
maxChannelDelta: 64,
|
||||
histogramSimilarity: 0.16,
|
||||
},
|
||||
colorAdjustOnly: {
|
||||
maxChannelDelta: 64,
|
||||
histogramSimilarity: 0.15,
|
||||
},
|
||||
curvesPlusColorAdjust: {
|
||||
maxChannelDelta: 72,
|
||||
histogramSimilarity: 0.16,
|
||||
},
|
||||
};
|
||||
|
||||
function createCurvesStep(): PipelineStep {
|
||||
return {
|
||||
nodeId: "curves-parity",
|
||||
type: "curves",
|
||||
params: {
|
||||
channelMode: "master",
|
||||
levels: {
|
||||
blackPoint: 0,
|
||||
whitePoint: 255,
|
||||
gamma: 1.18,
|
||||
},
|
||||
points: {
|
||||
rgb: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
red: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
green: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
blue: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createColorAdjustStep(): PipelineStep {
|
||||
return {
|
||||
nodeId: "color-adjust-parity",
|
||||
type: "color-adjust",
|
||||
params: {
|
||||
hsl: {
|
||||
hue: 0,
|
||||
saturation: 0,
|
||||
luminance: 9,
|
||||
},
|
||||
temperature: 6,
|
||||
tint: -4,
|
||||
vibrance: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createParityPipelines(): Record<ParityPipelineKey, ParityPipeline> {
|
||||
const curvesStep = createCurvesStep();
|
||||
const colorAdjustStep = createColorAdjustStep();
|
||||
|
||||
return {
|
||||
curvesOnly: {
|
||||
key: "curvesOnly",
|
||||
steps: [curvesStep],
|
||||
},
|
||||
colorAdjustOnly: {
|
||||
key: "colorAdjustOnly",
|
||||
steps: [colorAdjustStep],
|
||||
},
|
||||
curvesPlusColorAdjust: {
|
||||
key: "curvesPlusColorAdjust",
|
||||
steps: [curvesStep, colorAdjustStep],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFixturePixels(): Uint8ClampedArray {
|
||||
const pixels = new Uint8ClampedArray(FIXTURE_WIDTH * FIXTURE_HEIGHT * 4);
|
||||
|
||||
for (let y = 0; y < FIXTURE_HEIGHT; y += 1) {
|
||||
for (let x = 0; x < FIXTURE_WIDTH; x += 1) {
|
||||
const offset = (y * FIXTURE_WIDTH + x) * 4;
|
||||
pixels[offset] = (x * 31 + y * 17) % 256;
|
||||
pixels[offset + 1] = (x * 13 + y * 47) % 256;
|
||||
pixels[offset + 2] = (x * 57 + y * 19) % 256;
|
||||
pixels[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return pixels;
|
||||
}
|
||||
|
||||
function clonePixels(pixels: Uint8ClampedArray): Uint8ClampedArray {
|
||||
return new Uint8ClampedArray(pixels);
|
||||
}
|
||||
|
||||
type ShaderKind = "curves" | "color-adjust" | "vertex" | "unknown";
|
||||
|
||||
type FakeShader = {
|
||||
type: number;
|
||||
source: string;
|
||||
kind: ShaderKind;
|
||||
};
|
||||
|
||||
type FakeProgram = {
|
||||
attachedShaders: FakeShader[];
|
||||
kind: ShaderKind;
|
||||
uniforms: Map<string, number | [number, number, number]>;
|
||||
};
|
||||
|
||||
type FakeTexture = {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
type FakeFramebuffer = {
|
||||
attachment: FakeTexture | null;
|
||||
};
|
||||
|
||||
function createEmptyTexture(width: number, height: number): FakeTexture {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
data: new Uint8Array(width * height * 4),
|
||||
};
|
||||
}
|
||||
|
||||
function inferShaderKind(source: string): ShaderKind {
|
||||
if (source.includes("uGamma")) {
|
||||
return "curves";
|
||||
}
|
||||
if (source.includes("uColorShift")) {
|
||||
return "color-adjust";
|
||||
}
|
||||
if (source.includes("aPosition")) {
|
||||
return "vertex";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function toNormalized(value: number): number {
|
||||
return Math.max(0, Math.min(1, value / 255));
|
||||
}
|
||||
|
||||
function toByte(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
function runCurvesShader(input: Uint8Array, gamma: number): Uint8Array {
|
||||
const output = new Uint8Array(input.length);
|
||||
|
||||
for (let index = 0; index < input.length; index += 4) {
|
||||
const red = Math.pow(Math.max(toNormalized(input[index]), 0), Math.max(gamma, 0.001));
|
||||
const green = Math.pow(Math.max(toNormalized(input[index + 1]), 0), Math.max(gamma, 0.001));
|
||||
const blue = Math.pow(Math.max(toNormalized(input[index + 2]), 0), Math.max(gamma, 0.001));
|
||||
|
||||
output[index] = toByte(red);
|
||||
output[index + 1] = toByte(green);
|
||||
output[index + 2] = toByte(blue);
|
||||
output[index + 3] = input[index + 3] ?? 255;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function runColorAdjustShader(input: Uint8Array, shift: [number, number, number]): Uint8Array {
|
||||
const output = new Uint8Array(input.length);
|
||||
|
||||
for (let index = 0; index < input.length; index += 4) {
|
||||
const red = Math.max(0, Math.min(1, toNormalized(input[index]) + shift[0]));
|
||||
const green = Math.max(0, Math.min(1, toNormalized(input[index + 1]) + shift[1]));
|
||||
const blue = Math.max(0, Math.min(1, toNormalized(input[index + 2]) + shift[2]));
|
||||
|
||||
output[index] = toByte(red);
|
||||
output[index + 1] = toByte(green);
|
||||
output[index + 2] = toByte(blue);
|
||||
output[index + 3] = input[index + 3] ?? 255;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function createParityWebglContext(): WebGLRenderingContext {
|
||||
const glConstants = {
|
||||
VERTEX_SHADER: 0x8b31,
|
||||
FRAGMENT_SHADER: 0x8b30,
|
||||
COMPILE_STATUS: 0x8b81,
|
||||
LINK_STATUS: 0x8b82,
|
||||
ARRAY_BUFFER: 0x8892,
|
||||
STATIC_DRAW: 0x88e4,
|
||||
TRIANGLE_STRIP: 0x0005,
|
||||
FLOAT: 0x1406,
|
||||
TEXTURE_2D: 0x0de1,
|
||||
RGBA: 0x1908,
|
||||
UNSIGNED_BYTE: 0x1401,
|
||||
TEXTURE0: 0x84c0,
|
||||
TEXTURE_MIN_FILTER: 0x2801,
|
||||
TEXTURE_MAG_FILTER: 0x2800,
|
||||
TEXTURE_WRAP_S: 0x2802,
|
||||
TEXTURE_WRAP_T: 0x2803,
|
||||
CLAMP_TO_EDGE: 0x812f,
|
||||
NEAREST: 0x2600,
|
||||
FRAMEBUFFER: 0x8d40,
|
||||
COLOR_ATTACHMENT0: 0x8ce0,
|
||||
FRAMEBUFFER_COMPLETE: 0x8cd5,
|
||||
} as const;
|
||||
|
||||
let currentProgram: FakeProgram | null = null;
|
||||
let currentTexture: FakeTexture | null = null;
|
||||
let currentFramebuffer: FakeFramebuffer | null = null;
|
||||
|
||||
const gl = {
|
||||
...glConstants,
|
||||
createShader(shaderType: number): FakeShader {
|
||||
return {
|
||||
type: shaderType,
|
||||
source: "",
|
||||
kind: "unknown",
|
||||
};
|
||||
},
|
||||
shaderSource(shader: FakeShader, source: string) {
|
||||
shader.source = source;
|
||||
shader.kind = inferShaderKind(source);
|
||||
},
|
||||
compileShader() {},
|
||||
getShaderParameter() {
|
||||
return true;
|
||||
},
|
||||
getShaderInfoLog() {
|
||||
return null;
|
||||
},
|
||||
deleteShader() {},
|
||||
createProgram(): FakeProgram {
|
||||
return {
|
||||
attachedShaders: [],
|
||||
kind: "unknown",
|
||||
uniforms: new Map(),
|
||||
};
|
||||
},
|
||||
attachShader(program: FakeProgram, shader: FakeShader) {
|
||||
program.attachedShaders.push(shader);
|
||||
},
|
||||
linkProgram(program: FakeProgram) {
|
||||
const fragmentShader = program.attachedShaders.find((shader) => shader.type === glConstants.FRAGMENT_SHADER);
|
||||
program.kind = fragmentShader?.kind ?? "unknown";
|
||||
},
|
||||
getProgramParameter() {
|
||||
return true;
|
||||
},
|
||||
getProgramInfoLog() {
|
||||
return null;
|
||||
},
|
||||
deleteProgram() {},
|
||||
useProgram(program: FakeProgram) {
|
||||
currentProgram = program;
|
||||
},
|
||||
createBuffer() {
|
||||
return {};
|
||||
},
|
||||
bindBuffer() {},
|
||||
bufferData() {},
|
||||
getAttribLocation() {
|
||||
return 0;
|
||||
},
|
||||
enableVertexAttribArray() {},
|
||||
vertexAttribPointer() {},
|
||||
createTexture(): FakeTexture {
|
||||
return createEmptyTexture(1, 1);
|
||||
},
|
||||
bindTexture(_target: number, texture: FakeTexture | null) {
|
||||
currentTexture = texture;
|
||||
},
|
||||
texParameteri() {},
|
||||
texImage2D(
|
||||
_target: number,
|
||||
_level: number,
|
||||
_internalformat: number,
|
||||
width: number,
|
||||
height: number,
|
||||
_border: number,
|
||||
_format: number,
|
||||
_type: number,
|
||||
pixels: ArrayBufferView | null,
|
||||
) {
|
||||
if (!currentTexture) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pixels) {
|
||||
currentTexture.width = width;
|
||||
currentTexture.height = height;
|
||||
currentTexture.data = new Uint8Array(pixels.buffer.slice(0));
|
||||
return;
|
||||
}
|
||||
|
||||
currentTexture.width = width;
|
||||
currentTexture.height = height;
|
||||
currentTexture.data = new Uint8Array(width * height * 4);
|
||||
},
|
||||
activeTexture() {},
|
||||
getUniformLocation(program: FakeProgram, name: string) {
|
||||
return {
|
||||
program,
|
||||
name,
|
||||
};
|
||||
},
|
||||
uniform1i(location: { program: FakeProgram; name: string }, value: number) {
|
||||
location.program.uniforms.set(location.name, value);
|
||||
},
|
||||
uniform1f(location: { program: FakeProgram; name: string }, value: number) {
|
||||
location.program.uniforms.set(location.name, value);
|
||||
},
|
||||
uniform3f(location: { program: FakeProgram; name: string }, x: number, y: number, z: number) {
|
||||
location.program.uniforms.set(location.name, [x, y, z]);
|
||||
},
|
||||
createFramebuffer(): FakeFramebuffer {
|
||||
return {
|
||||
attachment: null,
|
||||
};
|
||||
},
|
||||
bindFramebuffer(_target: number, framebuffer: FakeFramebuffer | null) {
|
||||
currentFramebuffer = framebuffer;
|
||||
},
|
||||
framebufferTexture2D(
|
||||
_target: number,
|
||||
_attachment: number,
|
||||
_textarget: number,
|
||||
texture: FakeTexture | null,
|
||||
) {
|
||||
if (!currentFramebuffer) {
|
||||
return;
|
||||
}
|
||||
currentFramebuffer.attachment = texture;
|
||||
},
|
||||
checkFramebufferStatus() {
|
||||
return glConstants.FRAMEBUFFER_COMPLETE;
|
||||
},
|
||||
deleteFramebuffer() {},
|
||||
viewport() {},
|
||||
drawArrays() {
|
||||
if (!currentProgram || !currentTexture || !currentFramebuffer?.attachment) {
|
||||
throw new Error("Parity WebGL mock is missing required render state.");
|
||||
}
|
||||
|
||||
if (currentProgram.kind === "curves") {
|
||||
const gamma = Number(currentProgram.uniforms.get("uGamma") ?? 1);
|
||||
currentFramebuffer.attachment.data = runCurvesShader(currentTexture.data, gamma);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentProgram.kind === "color-adjust") {
|
||||
const colorShift = currentProgram.uniforms.get("uColorShift");
|
||||
const shift: [number, number, number] = Array.isArray(colorShift)
|
||||
? [colorShift[0] ?? 0, colorShift[1] ?? 0, colorShift[2] ?? 0]
|
||||
: [0, 0, 0];
|
||||
currentFramebuffer.attachment.data = runColorAdjustShader(currentTexture.data, shift);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported parity shader kind '${currentProgram.kind}'.`);
|
||||
},
|
||||
deleteTexture() {},
|
||||
readPixels(
|
||||
_x: number,
|
||||
_y: number,
|
||||
_width: number,
|
||||
_height: number,
|
||||
_format: number,
|
||||
_type: number,
|
||||
output: Uint8Array,
|
||||
) {
|
||||
if (!currentFramebuffer?.attachment) {
|
||||
throw new Error("Parity WebGL mock has no framebuffer attachment to read from.");
|
||||
}
|
||||
|
||||
output.set(currentFramebuffer.attachment.data);
|
||||
},
|
||||
};
|
||||
|
||||
return gl as unknown as WebGLRenderingContext;
|
||||
}
|
||||
|
||||
export function installParityWebglContextMock(): void {
|
||||
if (contextSpy) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => {
|
||||
if (contextId === "webgl" || contextId === "webgl2") {
|
||||
return createParityWebglContext();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreParityWebglContextMock(): void {
|
||||
if (!contextSpy) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextSpy.mockRestore();
|
||||
contextSpy = null;
|
||||
}
|
||||
|
||||
function buildHistogram(pixels: Uint8ClampedArray): HistogramBundle {
|
||||
const histogram: HistogramBundle = {
|
||||
red: Array.from({ length: 256 }, () => 0),
|
||||
green: Array.from({ length: 256 }, () => 0),
|
||||
blue: Array.from({ length: 256 }, () => 0),
|
||||
rgb: Array.from({ length: 256 }, () => 0),
|
||||
};
|
||||
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
const red = pixels[index] ?? 0;
|
||||
const green = pixels[index + 1] ?? 0;
|
||||
const blue = pixels[index + 2] ?? 0;
|
||||
const luma = Math.round(red * 0.299 + green * 0.587 + blue * 0.114);
|
||||
|
||||
histogram.red[red] += 1;
|
||||
histogram.green[green] += 1;
|
||||
histogram.blue[blue] += 1;
|
||||
histogram.rgb[Math.max(0, Math.min(255, luma))] += 1;
|
||||
}
|
||||
|
||||
return histogram;
|
||||
}
|
||||
|
||||
function channelIntersectionSimilarity(lhs: number[], rhs: number[], denominator: number): number {
|
||||
let overlap = 0;
|
||||
for (let index = 0; index < lhs.length; index += 1) {
|
||||
overlap += Math.min(lhs[index] ?? 0, rhs[index] ?? 0);
|
||||
}
|
||||
return overlap / Math.max(1, denominator);
|
||||
}
|
||||
|
||||
function calculateHistogramSimilarity(lhs: Uint8ClampedArray, rhs: Uint8ClampedArray): number {
|
||||
const lhsHistogram = buildHistogram(lhs);
|
||||
const rhsHistogram = buildHistogram(rhs);
|
||||
const totalPixels = lhs.length / 4;
|
||||
|
||||
const channels = [
|
||||
channelIntersectionSimilarity(lhsHistogram.red, rhsHistogram.red, totalPixels),
|
||||
channelIntersectionSimilarity(lhsHistogram.green, rhsHistogram.green, totalPixels),
|
||||
channelIntersectionSimilarity(lhsHistogram.blue, rhsHistogram.blue, totalPixels),
|
||||
channelIntersectionSimilarity(lhsHistogram.rgb, rhsHistogram.rgb, totalPixels),
|
||||
];
|
||||
|
||||
const sum = channels.reduce((acc, value) => acc + value, 0);
|
||||
return sum / channels.length;
|
||||
}
|
||||
|
||||
function calculateMaxChannelDelta(lhs: Uint8ClampedArray, rhs: Uint8ClampedArray): number {
|
||||
let maxDelta = 0;
|
||||
|
||||
for (let index = 0; index < lhs.length; index += 4) {
|
||||
const redDelta = Math.abs((lhs[index] ?? 0) - (rhs[index] ?? 0));
|
||||
const greenDelta = Math.abs((lhs[index + 1] ?? 0) - (rhs[index + 1] ?? 0));
|
||||
const blueDelta = Math.abs((lhs[index + 2] ?? 0) - (rhs[index + 2] ?? 0));
|
||||
maxDelta = Math.max(maxDelta, redDelta, greenDelta, blueDelta);
|
||||
}
|
||||
|
||||
return maxDelta;
|
||||
}
|
||||
|
||||
export function evaluateCpuWebglParity(pipeline: ParityPipeline): ParityMetrics {
|
||||
const source = createFixturePixels();
|
||||
const cpuPixels = clonePixels(source);
|
||||
const webglPixels = clonePixels(source);
|
||||
|
||||
for (const step of pipeline.steps) {
|
||||
applyPipelineStep(cpuPixels, step, FIXTURE_WIDTH, FIXTURE_HEIGHT);
|
||||
}
|
||||
|
||||
const webglBackend = createWebglPreviewBackend();
|
||||
for (const step of pipeline.steps) {
|
||||
webglBackend.runPreviewStep({
|
||||
pixels: webglPixels,
|
||||
step,
|
||||
width: FIXTURE_WIDTH,
|
||||
height: FIXTURE_HEIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
maxChannelDelta: calculateMaxChannelDelta(cpuPixels, webglPixels),
|
||||
histogramSimilarity: calculateHistogramSimilarity(cpuPixels, webglPixels),
|
||||
};
|
||||
}
|
||||
|
||||
export function enforceCpuWebglParityGates(): Record<ParityPipelineKey, ParityMetrics> {
|
||||
const pipelines = createParityPipelines();
|
||||
const metricsByPipeline = {} as Record<ParityPipelineKey, ParityMetrics>;
|
||||
|
||||
installParityWebglContextMock();
|
||||
try {
|
||||
for (const pipeline of Object.values(pipelines)) {
|
||||
const metrics = evaluateCpuWebglParity(pipeline);
|
||||
const tolerance = parityTolerances[pipeline.key];
|
||||
metricsByPipeline[pipeline.key] = metrics;
|
||||
|
||||
if (metrics.maxChannelDelta > tolerance.maxChannelDelta) {
|
||||
throw new Error(
|
||||
`${pipeline.key} parity max delta ${metrics.maxChannelDelta} exceeded tolerance ${tolerance.maxChannelDelta}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (metrics.histogramSimilarity < tolerance.histogramSimilarity) {
|
||||
throw new Error(
|
||||
`${pipeline.key} histogram similarity ${metrics.histogramSimilarity.toFixed(4)} below tolerance ${tolerance.histogramSimilarity.toFixed(4)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
restoreParityWebglContextMock();
|
||||
}
|
||||
|
||||
return metricsByPipeline;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
import { enforceCpuWebglParityGates } from "@/tests/image-pipeline/parity/fixtures";
|
||||
|
||||
function createCurvesStep(): PipelineStep {
|
||||
return {
|
||||
@@ -173,6 +174,8 @@ describe("webgl backend poc", () => {
|
||||
}
|
||||
|
||||
it("selects webgl for preview when webgl is available and enabled", async () => {
|
||||
enforceCpuWebglParityGates();
|
||||
|
||||
const webglPreview = vi.fn();
|
||||
|
||||
vi.doMock("@/lib/image-pipeline/backend/feature-flags", async () => {
|
||||
@@ -224,6 +227,8 @@ describe("webgl backend poc", () => {
|
||||
});
|
||||
|
||||
it("uses cpu for every step in a mixed pipeline request", async () => {
|
||||
enforceCpuWebglParityGates();
|
||||
|
||||
vi.doMock("@/lib/image-pipeline/backend/feature-flags", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/image-pipeline/backend/feature-flags")>(
|
||||
"@/lib/image-pipeline/backend/feature-flags",
|
||||
|
||||
Reference in New Issue
Block a user