Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- AlterTable
-- Nullable with no default, so this is a catalogue-only change on Postgres 11+:
-- no table rewrite and no long lock, which matters on a TestRun table holding
-- a hundred builds' worth of runs. Existing rows keep NULL and fall back to
-- computing the signature on demand.
ALTER TABLE "TestRun" ADD COLUMN "changeSignature" TEXT;
9 changes: 8 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x"]
}

Expand Down Expand Up @@ -72,6 +72,13 @@ model TestRun {
ignoreAreas String @default("[]")
tempIgnoreAreas String @default("[]")
vlmDescription String?
// Position-independent colour signature of this run's change, as JSON, so the
// variations dialog can group a screen's locales without fetching and
// decoding every sibling's screenshots at review time. Written once, next to
// the diff that already decoded them. Null on runs that predate this, that
// have no diff, or that were compared by something other than pixelmatch —
// those fall back to computing it on demand.
changeSignature String?
baseline Baseline?
build Build @relation(fields: [buildId], references: [id])
project Project? @relation(fields: [projectId], references: [id])
Expand Down
1 change: 1 addition & 0 deletions src/_data_/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const generateTestRun = (testRun?: Partial<TestRun>): TestRun => {
branchName: 'develop',
merge: false,
vlmDescription: null,
changeSignature: null,
...testRun,
};
};
Expand Down
87 changes: 87 additions & 0 deletions src/compare/libs/pixelmatch/pixelmatch.core.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { PNG } from 'pngjs';
import { computePixelmatchDiff } from './pixelmatch.core';
import { computeChangeSignature } from './signature.core';

const WIDTH = 60;
const HEIGHT = 60;

const png = (paint: (set: (x: number, y: number, rgb: [number, number, number]) => void) => void): Buffer => {
const image = new PNG({ width: WIDTH, height: HEIGHT });
for (let i = 0; i < WIDTH * HEIGHT; i++) {
image.data[i * 4] = 255;
image.data[i * 4 + 1] = 255;
image.data[i * 4 + 2] = 255;
image.data[i * 4 + 3] = 255;
}
paint((x, y, [r, g, b]) => {
const index = (y * WIDTH + x) * 4;
image.data[index] = r;
image.data[index + 1] = g;
image.data[index + 2] = b;
});
return PNG.sync.write(image);
};

const block = (left: number, top: number, rgb: [number, number, number]): Buffer =>
png((set) => {
for (let y = top; y < top + 12; y++) {
for (let x = left; x < left + 12; x++) {
set(x, y, rgb);
}
}
});

const blank = png(() => undefined);
const RED: [number, number, number] = [255, 0, 0];

const diffJob = (baseline: Buffer, image: Buffer, withSignature: boolean) =>
computePixelmatchDiff({
kind: 'diff',
baseline,
image,
ignoreAreas: [],
threshold: 0.1,
includeAA: false,
allowDiffDimensions: false,
diffTolerancePercent: 0,
saveDiff: false,
withSignature,
});

describe('computePixelmatchDiff with a signature', () => {
// The whole point of computing it here is to reuse the decode the diff
// already paid for. If the fused version answered differently from the
// standalone one, stored signatures would stop matching computed ones and
// variations would silently stop grouping.
it('gives the same signature the standalone job would', () => {
const image = block(10, 10, RED);

const fused = diffJob(blank, image, true).signature;
const standalone = computeChangeSignature({
kind: 'signature',
baseline: blank,
image,
ignoreAreas: [],
threshold: 0.1,
includeAA: false,
}).signature;

expect(fused).toEqual(standalone);
expect(fused).not.toBeNull();
});

it('leaves the signature out when it was not asked for', () => {
expect(diffJob(blank, block(10, 10, RED), false).signature).toBeUndefined();
});

it('has no signature when the screenshots are identical', () => {
expect(diffJob(blank, blank, true).signature).toBeUndefined();
});

it('still reports the diff it was asked for', () => {
const result = diffJob(blank, block(10, 10, RED), true);

expect(result.equal).toBe(false);
expect(result.pixelMisMatchCount).toBe(144);
});
});
31 changes: 30 additions & 1 deletion src/compare/libs/pixelmatch/pixelmatch.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PNG } from 'pngjs';
import Pixelmatch from 'pixelmatch';
import { IgnoreAreaDto } from '../../../test-runs/dto/ignore-area.dto';
import { applyIgnoreAreas, scaleImageToSize } from '../../utils';
import { signatureOfDecoded } from './signature.core';

/**
* CPU-bound part of the pixelmatch comparison, extracted so it can run inside
Expand All @@ -20,6 +21,13 @@ export interface PixelmatchJobInput {
allowDiffDimensions: boolean;
diffTolerancePercent: number;
saveDiff: boolean;
/**
* Also produce the change signature the variations dialog matches on. Free
* here next to the diff — the images are decoded already — and computed once
* at ingest so reviewing never has to fetch and decode a build's screenshots
* again just to group them.
*/
withSignature?: boolean;
}

export interface PixelmatchJobOutput {
Expand All @@ -28,6 +36,9 @@ export interface PixelmatchJobOutput {
pixelMisMatchCount?: number;
diffPercent?: number;
diffBuffer?: Buffer | Uint8Array;
// absent when not asked for, when the screenshots are identical, or when
// their dimensions differ and there is nothing meaningful to sign
signature?: number[];
}

// postMessage turns Buffers into Uint8Array views over their own ArrayBuffer.
Expand Down Expand Up @@ -74,5 +85,23 @@ export function computePixelmatchDiff(input: PixelmatchJobInput): PixelmatchJobO
diffBuffer = PNG.sync.write(diff);
}

return { equal: false, isSameDimension, pixelMisMatchCount, diffPercent, diffBuffer };
// Same source images, same ignore areas, same threshold as the standalone
// signature job — sharing signatureOfDecoded is what keeps the two answers
// identical, so a stored signature still matches one computed on the fly.
const signature =
input.withSignature && isSameDimension
? signatureOfDecoded(baselineIgnored, imageIgnored, {
threshold: input.threshold,
includeAA: input.includeAA,
})
: null;

return {
equal: false,
isSameDimension,
pixelMisMatchCount,
diffPercent,
diffBuffer,
...(signature ? { signature } : {}),
};
}
72 changes: 72 additions & 0 deletions src/compare/libs/pixelmatch/pixelmatch.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,75 @@ describe('getDiff', () => {
});
});
});

describe('change signature', () => {
// Correctness of the signature itself is pinned in pixelmatch.core.spec
// against the real pixelmatch; Pixelmatch is mocked in this file, so what is
// worth testing here is only the plumbing — that the service asks for one and
// carries what comes back into the result.
const initWithPool = async (output: Record<string, unknown>) => {
const run = jest.fn().mockResolvedValue(output);
const module: TestingModule = await Test.createTestingModule({
providers: [
PixelmatchService,
{ provide: DiffWorkerPool, useValue: { run } },
{
provide: StaticService,
useValue: {
getImageBuffer: jest.fn().mockResolvedValue(Buffer.from('png')),
saveImage: jest.fn(),
deleteImage: jest.fn(),
},
},
],
}).compile();
return { service: module.get<PixelmatchService>(PixelmatchService), run };
};

const compare = (service: PixelmatchService) =>
service.getDiff(
{
baseline: 'baseline.png',
image: 'image.png',
ignoreAreas: [],
diffTollerancePercent: 0,
saveDiffAsFile: false,
},
DEFAULT_CONFIG
);

// Asked for on every comparison, not only when the project has bulk approve
// switched on: computing it lazily would leave every run ingested before the
// flag was turned on without one.
it('asks for a signature and carries it into the result', async () => {
const { service, run } = await initWithPool({
equal: false,
isSameDimension: true,
pixelMisMatchCount: 10,
diffPercent: 5,
signature: [0.25, 0.75],
});

const result = await compare(service);

expect(run).toHaveBeenCalledWith(expect.objectContaining({ kind: 'diff', withSignature: true }));
// stamped with the settings it was computed under, so a stored one can be
// discarded when the project's config moves on
expect(result.changeSignature).toEqual({
threshold: DEFAULT_CONFIG.threshold,
includeAA: DEFAULT_CONFIG.ignoreAntialiasing,
signature: [0.25, 0.75],
});
});

it('leaves it out when the comparison produced none', async () => {
const { service } = await initWithPool({
equal: false,
isSameDimension: true,
pixelMisMatchCount: 10,
diffPercent: 5,
});

expect((await compare(service)).changeSignature).toBeUndefined();
});
});
15 changes: 15 additions & 0 deletions src/compare/libs/pixelmatch/pixelmatch.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export class PixelmatchService implements ImageComparator {
allowDiffDimensions: config.allowDiffDimensions,
diffTolerancePercent: data.diffTollerancePercent,
saveDiff: data.saveDiffAsFile,
// Asked for on every comparison, not only when the project has bulk
// approve of variations switched on: the pass costs little beside the
// full-size diff that has already been paid for, and computing it lazily
// would leave every run ingested before the flag was turned on without
// one — which is exactly the build someone then tries to review.
withSignature: true,
});

if (output.equal) {
Expand All @@ -59,6 +65,15 @@ export class PixelmatchService implements ImageComparator {
isSameDimension: output.isSameDimension,
pixelMisMatchCount: output.pixelMisMatchCount,
diffPercent: output.diffPercent,
...(output.signature
? {
changeSignature: {
threshold: config.threshold,
includeAA: config.ignoreAntialiasing,
signature: output.signature,
},
}
: {}),
};

if (result.diffPercent > data.diffTollerancePercent) {
Expand Down
30 changes: 25 additions & 5 deletions src/compare/libs/pixelmatch/signature.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export interface SignatureJobOutput {
// COLOR_BUCKETS_PER_CHANNEL^3 histogram buckets.
const COLOR_BUCKETS_PER_CHANNEL = 4;

// How many buckets a signature has. Exported so a stored one can be checked
// against the shape this build produces: comparing signatures of two different
// lengths yields a similarity score computed over undefined entries — a number,
// and a meaningless one.
export const SIGNATURE_LENGTH = COLOR_BUCKETS_PER_CHANNEL ** 3;

// Longest side (px) images are downscaled to before computing the color
// signature — keeps the histogram representative while cutting pixelmatch cost.
const SIGNATURE_MAX_DIMENSION = 500;
Expand Down Expand Up @@ -106,17 +112,31 @@ export function computeChangeSignature(input: SignatureJobInput): SignatureJobOu
applyIgnoreAreas(baselineImage, input.ignoreAreas);
applyIgnoreAreas(checkpointImage, input.ignoreAreas);

return { signature: signatureOfDecoded(baselineImage, checkpointImage, input) };
}

/**
* The signature of a pair that is already decoded, of equal size, and with its
* ignore areas blanked. Split out so the diff can produce a signature from the
* images it has just decoded rather than paying for a second decode — the two
* must not drift apart, so there is only ever one implementation.
*/
export function signatureOfDecoded(
baselineImage: RawImage,
checkpointImage: RawImage,
options: { threshold: number; includeAA: boolean }
): number[] | null {
const baseline = downscale(baselineImage, SIGNATURE_MAX_DIMENSION);
const image = downscale(checkpointImage, SIGNATURE_MAX_DIMENSION);
const { width, height } = baseline;
const mask = new PNG({ width, height });
const changedPixels = Pixelmatch(baseline.data, image.data, mask.data, width, height, {
threshold: input.threshold,
includeAA: input.includeAA,
threshold: options.threshold,
includeAA: options.includeAA,
diffMask: true,
});
if (changedPixels === 0) {
return { signature: null };
return null;
}

const bucketSize = 256 / COLOR_BUCKETS_PER_CHANNEL;
Expand All @@ -133,9 +153,9 @@ export function computeChangeSignature(input: SignatureJobInput): SignatureJobOu

const total = histogram.reduce((sum, value) => sum + value, 0);
if (total === 0) {
return { signature: null };
return null;
}
return { signature: histogram.map((value) => value / total) };
return histogram.map((value) => value / total);
}

function cosineSimilarity(a: number[], b: number[]): number {
Expand Down
21 changes: 21 additions & 0 deletions src/test-runs/diffResult.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { TestStatus } from '@prisma/client';

/**
* A signature and the comparison settings it was computed under. A signature
* only means anything under its own threshold, so comparing one taken at a
* different setting quietly makes grouping worse — the settings travel with it
* so a stored one can be discarded when the project's config moves on.
*/
export interface StampedSignature {
threshold: number;
includeAA: boolean;
signature: number[];
}

export interface DiffResult {
status: TestStatus;
diffName: string;
Expand All @@ -12,4 +24,13 @@ export interface DiffResult {
* Can be displayed as bullet points in UI
*/
vlmDescription?: string;
/**
* Position-independent colour signature of the change, produced by the same
* pass that produced the diff, carrying the settings it was produced under.
* Stored on the run so the variations dialog can group a screen's locales
* without decoding their screenshots all over again. Absent when nothing
* changed, when the dimensions differ, or when the comparison was not
* pixelmatch.
*/
changeSignature?: StampedSignature;
}
Loading
Loading