pcbjam/tests/tools/screenshots/image-ops.ts
Viktor Vaczi 9787efc2c9 feat(tests): caption posted screenshots (name + spec); raise drift floor to 0.5%
Bake a caption strip onto every posted screenshot composite — status + name +
the spec that produces it — for changed, added, and removed; removed now shows
the old baseline image (was a text-only line). Zero native-dep: an embedded
public-domain 8x8 bitmap font (font8x8.ts) rendered by image-ops `withBottomLabel`;
the name→spec attribution is factored out of gen-manifest into a shared
`spec-map.ts` resolver. Bottom strip, colour per status (green/red/orange).
Applies in both compare (drift gate) and changelog (git-history diff), and
post-discord now attaches the captioned removed images.

Also raise the per-engine drift floor 0.2% → 0.5% (changedRatio) to absorb the
sub-1% inter-run flakiness seen after the re-baseline, while still catching real
localized changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:07:41 +02:00

316 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Reusable pixel operations for the screenshot tooling: PNG load/save, padding,
* the pixelmatch-backed diff, connected-component clustering ("where to look"),
* box drawing, and horizontal compositing for the triptych.
*
* All images are handled as pngjs PNGs whose `.data` is a length `w*h*4` RGBA
* Buffer, regardless of the source PNG colour type (pngjs normalizes to RGBA).
*/
import * as fs from 'fs';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
import { PIXELMATCH, DIFF_COLOR, CLUSTER, TRIPTYCH, LABEL } from './config';
import { glyphFor } from './font8x8';
export type Box = { x: number; y: number; width: number; height: number; area: number };
export type DiffResult = {
width: number;
height: number;
dimsMatch: boolean;
/** AA-excluded changed-pixel count (from pixelmatch). */
diffPixels: number;
/** diffPixels / (width*height). */
changedRatio: number;
/** mean |Δ| over every RGBA channel sample of the whole frame (matches the legacy metric). */
meanChannelDiff: number;
/** pixelmatch heatmap: dimmed base + red diffs / yellow AA. */
heatmap: PNG;
/** boolean mask (1 = real, non-AA changed pixel) for clustering. */
mask: Uint8Array;
};
export function loadPng(file: string): PNG {
return PNG.sync.read(fs.readFileSync(file));
}
export function savePng(file: string, png: PNG): void {
fs.writeFileSync(file, PNG.sync.write(png));
}
/** New PNG of `w`×`h` filled with `fill` (RGBA), with `src` blitted at top-left. */
export function padTo(src: PNG, w: number, h: number, fill: [number, number, number, number]): PNG {
const out = new PNG({ width: w, height: h });
for (let i = 0; i < out.data.length; i += 4) {
out.data[i] = fill[0];
out.data[i + 1] = fill[1];
out.data[i + 2] = fill[2];
out.data[i + 3] = fill[3];
}
for (let y = 0; y < Math.min(h, src.height); y++) {
const srcRow = y * src.width * 4;
const dstRow = y * w * 4;
const rowBytes = Math.min(w, src.width) * 4;
src.data.copy(out.data, dstRow, srcRow, srcRow + rowBytes);
}
return out;
}
/**
* Diff two images. On a dimension mismatch both are padded (magenta) to the
* union size and `dimsMatch` is false (the caller treats that as CHANGED).
* The changed-pixel mask is read back from the heatmap's red diff pixels, so it
* inherits pixelmatch's anti-aliasing exclusion.
*/
export function diffImages(a: PNG, b: PNG): DiffResult {
const dimsMatch = a.width === b.width && a.height === b.height;
const width = Math.max(a.width, b.width);
const height = Math.max(a.height, b.height);
const pa = dimsMatch ? a : padTo(a, width, height, TRIPTYCH.padFill);
const pb = dimsMatch ? b : padTo(b, width, height, TRIPTYCH.padFill);
const heatmap = new PNG({ width, height });
const diffPixels = pixelmatch(pa.data, pb.data, heatmap.data, width, height, {
threshold: PIXELMATCH.threshold,
includeAA: PIXELMATCH.includeAA,
diffColor: DIFF_COLOR,
});
// Whole-frame mean channel delta (drift-vs-regression heuristic input).
let totalChannelDiff = 0;
for (let i = 0; i < pa.data.length; i++) {
totalChannelDiff += Math.abs(pa.data[i] - pb.data[i]);
}
const meanChannelDiff = totalChannelDiff / pa.data.length;
// Mask = heatmap pixels painted with DIFF_COLOR (red). AA pixels are yellow, so excluded.
const mask = new Uint8Array(width * height);
for (let p = 0; p < width * height; p++) {
const o = p * 4;
if (heatmap.data[o] > 200 && heatmap.data[o + 1] < 80 && heatmap.data[o + 2] < 80) {
mask[p] = 1;
}
}
return {
width,
height,
dimsMatch,
diffPixels,
changedRatio: diffPixels / (width * height),
meanChannelDiff,
heatmap,
mask,
};
}
/** Dilate a boolean mask by `r` (square structuring element), out of place. */
function dilate(mask: Uint8Array, w: number, h: number, r: number): Uint8Array {
if (r <= 0) return mask;
const out = new Uint8Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (!mask[y * w + x]) continue;
const y0 = Math.max(0, y - r);
const y1 = Math.min(h - 1, y + r);
const x0 = Math.max(0, x - r);
const x1 = Math.min(w - 1, x + r);
for (let yy = y0; yy <= y1; yy++) {
for (let xx = x0; xx <= x1; xx++) out[yy * w + xx] = 1;
}
}
}
return out;
}
/**
* 8-connected connected-components over the (dilated) mask → bounding boxes,
* largest-area first, capped at `maxBoxes`, specks below `minBoxArea` dropped.
*/
export function cluster(mask: Uint8Array, w: number, h: number): Box[] {
const grown = dilate(mask, w, h, CLUSTER.dilate);
const seen = new Uint8Array(w * h);
const boxes: Box[] = [];
const stack: number[] = [];
for (let start = 0; start < grown.length; start++) {
if (!grown[start] || seen[start]) continue;
let minX = w, minY = h, maxX = 0, maxY = 0, count = 0;
stack.push(start);
seen[start] = 1;
while (stack.length) {
const p = stack.pop()!;
const px = p % w;
const py = (p - px) / w;
count++;
if (px < minX) minX = px;
if (px > maxX) maxX = px;
if (py < minY) minY = py;
if (py > maxY) maxY = py;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = px + dx;
const ny = py + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
const np = ny * w + nx;
if (grown[np] && !seen[np]) {
seen[np] = 1;
stack.push(np);
}
}
}
}
const bw = maxX - minX + 1;
const bh = maxY - minY + 1;
const area = bw * bh;
if (area >= CLUSTER.minBoxArea) {
boxes.push({ x: minX, y: minY, width: bw, height: bh, area });
}
}
boxes.sort((p, q) => q.area - p.area);
return boxes.slice(0, CLUSTER.maxBoxes);
}
/** Draw 2px rectangle outlines for each box onto a copy of `png`. */
export function drawBoxes(png: PNG, boxes: Box[]): PNG {
const out = new PNG({ width: png.width, height: png.height });
png.data.copy(out.data);
const [r, g, b] = CLUSTER.boxColor;
const set = (x: number, y: number) => {
if (x < 0 || y < 0 || x >= out.width || y >= out.height) return;
const o = (y * out.width + x) * 4;
out.data[o] = r;
out.data[o + 1] = g;
out.data[o + 2] = b;
out.data[o + 3] = 255;
};
for (const box of boxes) {
for (let t = 0; t < 2; t++) {
for (let x = box.x; x < box.x + box.width; x++) {
set(x, box.y + t);
set(x, box.y + box.height - 1 - t);
}
for (let y = box.y; y < box.y + box.height; y++) {
set(box.x + t, y);
set(box.x + box.width - 1 - t, y);
}
}
}
return out;
}
/** Nearest-neighbour downscale by `scale` (0<scale<1). Fast, quality secondary — it only exists to fit Discord's size caps. */
export function resizeNearest(png: PNG, scale: number): PNG {
const w = Math.max(1, Math.round(png.width * scale));
const h = Math.max(1, Math.round(png.height * scale));
const out = new PNG({ width: w, height: h });
for (let y = 0; y < h; y++) {
const sy = Math.min(png.height - 1, Math.floor(y / scale));
for (let x = 0; x < w; x++) {
const sx = Math.min(png.width - 1, Math.floor(x / scale));
const s = (sy * png.width + sx) * 4;
const d = (y * w + x) * 4;
out.data[d] = png.data[s];
out.data[d + 1] = png.data[s + 1];
out.data[d + 2] = png.data[s + 2];
out.data[d + 3] = png.data[s + 3];
}
}
return out;
}
/** Encode `png`, halving resolution until the PNG is <= maxBytes (or it can't shrink further). */
export function encodeWithinCap(png: PNG, maxBytes: number): Buffer {
let current = png;
let buf = PNG.sync.write(current);
while (buf.length > maxBytes && current.width > 320) {
current = resizeNearest(current, 0.5);
buf = PNG.sync.write(current);
}
return buf;
}
/** Horizontally montage images (heights normalized to the tallest) with a gap + bg. */
export function composite(panels: PNG[]): PNG {
const gap = TRIPTYCH.gap;
const bg = TRIPTYCH.bg;
const height = Math.max(...panels.map((p) => p.height));
const width = panels.reduce((s, p) => s + p.width, 0) + gap * (panels.length - 1);
const out = new PNG({ width, height });
for (let i = 0; i < out.data.length; i += 4) {
out.data[i] = bg[0];
out.data[i + 1] = bg[1];
out.data[i + 2] = bg[2];
out.data[i + 3] = bg[3];
}
let xOffset = 0;
for (const panel of panels) {
for (let y = 0; y < panel.height; y++) {
const srcRow = y * panel.width * 4;
const dstRow = (y * width + xOffset) * 4;
panel.data.copy(out.data, dstRow, srcRow, srcRow + panel.width * 4);
}
xOffset += panel.width + gap;
}
return out;
}
/** Draw `text` at (x0,y0) with the 8x8 bitmap font, each pixel scaled `scale`×. Clips at edges. */
export function drawText(png: PNG, x0: number, y0: number, text: string, color: [number, number, number], scale: number): void {
let x = x0;
for (const ch of text) {
const glyph = glyphFor(ch);
for (let row = 0; row < 8; row++) {
const bits = glyph[row];
if (!bits) continue;
for (let col = 0; col < 8; col++) {
if (!((bits >> col) & 1)) continue;
for (let dy = 0; dy < scale; dy++) {
const py = y0 + row * scale + dy;
if (py < 0 || py >= png.height) continue;
for (let dx = 0; dx < scale; dx++) {
const px = x + col * scale + dx;
if (px < 0 || px >= png.width) continue;
const o = (py * png.width + px) * 4;
png.data[o] = color[0];
png.data[o + 1] = color[1];
png.data[o + 2] = color[2];
png.data[o + 3] = 255;
}
}
}
}
x += 9 * scale; // 8px glyph + 1px spacing
}
}
/**
* Return a copy of `png` with a `bg`-coloured caption strip appended at the bottom,
* showing `text` in white. Auto-fits the font scale to the width; if even scale 1
* overflows, truncates the tail (keeps the name, trims the spec) with `..`.
*/
export function withBottomLabel(png: PNG, text: string, bg: [number, number, number]): PNG {
const advance = 9; // per-char glyph cells at scale 1
const maxW = Math.max(1, png.width - 2 * LABEL.hpad);
let scale = LABEL.maxScale;
while (scale > 1 && text.length * advance * scale > maxW) scale--;
let label = text;
const maxChars = Math.max(1, Math.floor(maxW / (advance * scale)));
if (label.length > maxChars) label = label.slice(0, Math.max(1, maxChars - 2)) + '..';
const stripH = 8 * scale + 2 * LABEL.vpad;
const out = new PNG({ width: png.width, height: png.height + stripH });
png.data.copy(out.data, 0, 0, png.data.length); // original image on top (same width)
for (let y = png.height; y < out.height; y++) {
for (let x = 0; x < out.width; x++) {
const o = (y * out.width + x) * 4;
out.data[o] = bg[0];
out.data[o + 1] = bg[1];
out.data[o + 2] = bg[2];
out.data[o + 3] = 255;
}
}
drawText(out, LABEL.hpad, png.height + LABEL.vpad, label, LABEL.text, scale);
return out;
}