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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||||
|
import { enforceCpuWebglParityGates } from "@/tests/image-pipeline/parity/fixtures";
|
||||||
|
|
||||||
function createCurvesStep(): PipelineStep {
|
function createCurvesStep(): PipelineStep {
|
||||||
return {
|
return {
|
||||||
@@ -173,6 +174,8 @@ describe("webgl backend poc", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("selects webgl for preview when webgl is available and enabled", async () => {
|
it("selects webgl for preview when webgl is available and enabled", async () => {
|
||||||
|
enforceCpuWebglParityGates();
|
||||||
|
|
||||||
const webglPreview = vi.fn();
|
const webglPreview = vi.fn();
|
||||||
|
|
||||||
vi.doMock("@/lib/image-pipeline/backend/feature-flags", async () => {
|
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 () => {
|
it("uses cpu for every step in a mixed pipeline request", async () => {
|
||||||
|
enforceCpuWebglParityGates();
|
||||||
|
|
||||||
vi.doMock("@/lib/image-pipeline/backend/feature-flags", async () => {
|
vi.doMock("@/lib/image-pipeline/backend/feature-flags", async () => {
|
||||||
const actual = await vi.importActual<typeof import("@/lib/image-pipeline/backend/feature-flags")>(
|
const actual = await vi.importActual<typeof import("@/lib/image-pipeline/backend/feature-flags")>(
|
||||||
"@/lib/image-pipeline/backend/feature-flags",
|
"@/lib/image-pipeline/backend/feature-flags",
|
||||||
|
|||||||
Reference in New Issue
Block a user