mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 15:46:30 +03:00
Add comp-diff, comp-spec, and build-phase: measured comp fidelity for the build phase
Dependency-free PNG codec, perceptual metrics (structure / color / detail / bands), side-by-side + heatmap + per-region crops, a measured spec from the approved comp (grid overlay, sampled palette, plate list), and a phase state machine whose spec / plates / hero gates run the diff instead of asking the model to remember the image. AI-assisted (Claude). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
Abdul Wahab
co-authored by
Claude
parent
f86473ba7d
commit
b0fc2e8801
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Perceptual measures for comparing a comp with a build screenshot. Pure
|
||||
* functions over `{ width, height, data }` RGBA images; no I/O.
|
||||
*
|
||||
* Three families, because a build fails a comp in three separable ways:
|
||||
*
|
||||
* - structure: is the composition the same? Measured as SSIM over a blurred
|
||||
* grayscale downsample, which forgives font hinting and a few pixels of
|
||||
* drift and punishes a moved, missing, or invented region.
|
||||
* - color: is the palette and its distribution the same? Histogram
|
||||
* intersection in a coarse quantized space plus a dominant-color extraction,
|
||||
* so a navy page built from a bone comp fails even if the shapes match.
|
||||
* - detail: is the material there? Local high-frequency energy per cell. A
|
||||
* comp with an illustration, texture, or photograph carries energy a flat
|
||||
* CSS stand-in does not; the ratio build/comp per region is the most direct
|
||||
* measure of "the plate got replaced by a gradient".
|
||||
*/
|
||||
import { resize } from './raster.mjs';
|
||||
|
||||
export function toGray(img) {
|
||||
const g = new Float32Array(img.width * img.height);
|
||||
for (let i = 0, p = 0; i < g.length; i++, p += 4) {
|
||||
const a = img.data[p + 3] / 255;
|
||||
// composite over white so transparent regions read as the page ground
|
||||
const r = img.data[p] * a + 255 * (1 - a), gg = img.data[p + 1] * a + 255 * (1 - a), b = img.data[p + 2] * a + 255 * (1 - a);
|
||||
g[i] = 0.2126 * r + 0.7152 * gg + 0.0722 * b;
|
||||
}
|
||||
return { width: img.width, height: img.height, data: g };
|
||||
}
|
||||
|
||||
/** Separable box blur on a float gray image, radius r. */
|
||||
export function blurGray(gray, r) {
|
||||
if (r <= 0) return gray;
|
||||
const { width, height, data } = gray;
|
||||
const tmp = new Float32Array(data.length), out = new Float32Array(data.length);
|
||||
const win = 2 * r + 1;
|
||||
for (let y = 0; y < height; y++) {
|
||||
let acc = 0;
|
||||
for (let x = -r; x <= r; x++) acc += data[y * width + Math.min(width - 1, Math.max(0, x))];
|
||||
for (let x = 0; x < width; x++) {
|
||||
tmp[y * width + x] = acc / win;
|
||||
const outX = x - r, inX = x + r + 1;
|
||||
acc += data[y * width + Math.min(width - 1, inX)] - data[y * width + Math.max(0, outX)];
|
||||
}
|
||||
}
|
||||
for (let x = 0; x < width; x++) {
|
||||
let acc = 0;
|
||||
for (let y = -r; y <= r; y++) acc += tmp[Math.min(height - 1, Math.max(0, y)) * width + x];
|
||||
for (let y = 0; y < height; y++) {
|
||||
out[y * width + x] = acc / win;
|
||||
const outY = y - r, inY = y + r + 1;
|
||||
acc += tmp[Math.min(height - 1, inY) * width + x] - tmp[Math.max(0, outY) * width + x];
|
||||
}
|
||||
}
|
||||
return { width, height, data: out };
|
||||
}
|
||||
|
||||
/** Global SSIM between two same-size gray images using an 8x8 window grid. */
|
||||
export function ssim(a, b, win = 8) {
|
||||
if (a.width !== b.width || a.height !== b.height) throw new Error('ssim: size mismatch');
|
||||
const C1 = (0.01 * 255) ** 2, C2 = (0.03 * 255) ** 2;
|
||||
let total = 0, n = 0;
|
||||
for (let y = 0; y + win <= a.height; y += win) {
|
||||
for (let x = 0; x + win <= a.width; x += win) {
|
||||
let ma = 0, mb = 0;
|
||||
for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; ma += a.data[i]; mb += b.data[i]; }
|
||||
ma /= win * win; mb /= win * win;
|
||||
let va = 0, vb = 0, cov = 0;
|
||||
for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; const da = a.data[i] - ma, db = b.data[i] - mb; va += da * da; vb += db * db; cov += da * db; }
|
||||
va /= win * win - 1; vb /= win * win - 1; cov /= win * win - 1;
|
||||
total += ((2 * ma * mb + C1) * (2 * cov + C2)) / ((ma * ma + mb * mb + C1) * (va + vb + C2));
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n ? total / n : 1;
|
||||
}
|
||||
|
||||
/** SSIM of `a` against `b` shifted by (dx, dy); the overlap is compared, edges dropped. */
|
||||
function ssimShifted(a, b, dx, dy, win) {
|
||||
const w = a.width - Math.abs(dx), h = a.height - Math.abs(dy);
|
||||
if (w < win || h < win) return 0;
|
||||
const sa = { width: w, height: h, data: new Float32Array(w * h) };
|
||||
const sb = { width: w, height: h, data: new Float32Array(w * h) };
|
||||
const ax = Math.max(0, -dx), ay = Math.max(0, -dy), bx = Math.max(0, dx), by = Math.max(0, dy);
|
||||
for (let y = 0; y < h; y++) {
|
||||
sa.data.set(a.data.subarray((y + ay) * a.width + ax, (y + ay) * a.width + ax + w), y * w);
|
||||
sb.data.set(b.data.subarray((y + by) * b.width + bx, (y + by) * b.width + bx + w), y * w);
|
||||
}
|
||||
return ssim(sa, sb, win);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure score 0..1: SSIM over blurred grayscale at a fixed working width,
|
||||
* taking the best of a small translation search so a composition that sits a
|
||||
* few pixels off (a different masthead height, a scrollbar) is not read as a
|
||||
* different composition. Shifts up to ~4% of the width are forgiven; a moved
|
||||
* region is not.
|
||||
*/
|
||||
export function structureScore(imgA, imgB, workWidth = 256) {
|
||||
const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth));
|
||||
const a = blurGray(toGray(resize(imgA, workWidth, h)), 2);
|
||||
const b = blurGray(toGray(resize(imgB, workWidth, h)), 2);
|
||||
const win = Math.min(8, Math.max(2, Math.floor(Math.min(workWidth, h) / 8)));
|
||||
let best = ssim(a, b, win);
|
||||
const maxShift = Math.max(2, Math.round(workWidth * 0.04));
|
||||
for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
|
||||
for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) {
|
||||
if (!dx && !dy) continue;
|
||||
best = Math.max(best, ssimShifted(a, b, Math.round(dx), Math.round(dy), win));
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.min(1, best));
|
||||
}
|
||||
|
||||
// ---- color -----------------------------------------------------------------
|
||||
|
||||
function rgbToLab(r, g, b) {
|
||||
const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
const R = lin(r), G = lin(g), B = lin(b);
|
||||
const X = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
|
||||
const Y = (R * 0.2126 + G * 0.7152 + B * 0.0722) / 1.0;
|
||||
const Z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
|
||||
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
||||
const fx = f(X), fy = f(Y), fz = f(Z);
|
||||
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
||||
}
|
||||
|
||||
export function deltaE(lab1, lab2) {
|
||||
return Math.hypot(lab1[0] - lab2[0], lab1[1] - lab2[1], lab1[2] - lab2[2]);
|
||||
}
|
||||
|
||||
/** Quantized color histogram (4 bits per channel = 4096 bins), normalized. */
|
||||
export function colorHistogram(img, sampleStep = 2) {
|
||||
const bins = new Float32Array(4096);
|
||||
let n = 0;
|
||||
for (let y = 0; y < img.height; y += sampleStep) {
|
||||
for (let x = 0; x < img.width; x += sampleStep) {
|
||||
const p = (y * img.width + x) * 4;
|
||||
if (img.data[p + 3] < 16) continue;
|
||||
const key = ((img.data[p] >> 4) << 8) | ((img.data[p + 1] >> 4) << 4) | (img.data[p + 2] >> 4);
|
||||
bins[key]++; n++;
|
||||
}
|
||||
}
|
||||
if (n) for (let i = 0; i < bins.length; i++) bins[i] /= n;
|
||||
return bins;
|
||||
}
|
||||
|
||||
export function histogramIntersection(h1, h2) {
|
||||
let s = 0;
|
||||
for (let i = 0; i < h1.length; i++) s += Math.min(h1[i], h2[i]);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dominant colors: merge histogram bins greedily by Lab distance into up to
|
||||
* `k` clusters and return them sorted by coverage.
|
||||
*/
|
||||
export function dominantColors(img, k = 6, sampleStep = 3) {
|
||||
const hist = colorHistogram(img, sampleStep);
|
||||
const entries = [];
|
||||
for (let i = 0; i < hist.length; i++) if (hist[i] > 0.0005) entries.push({ key: i, w: hist[i] });
|
||||
entries.sort((a, b) => b.w - a.w);
|
||||
const clusters = [];
|
||||
for (const e of entries) {
|
||||
const r = ((e.key >> 8) & 15) * 16 + 8, g = ((e.key >> 4) & 15) * 16 + 8, b = (e.key & 15) * 16 + 8;
|
||||
const lab = rgbToLab(r, g, b);
|
||||
let best = null, bestD = Infinity;
|
||||
for (const c of clusters) { const d = deltaE(c.lab, lab); if (d < bestD) { bestD = d; best = c; } }
|
||||
if (best && bestD < 14) {
|
||||
const tw = best.w + e.w;
|
||||
best.rgb = [(best.rgb[0] * best.w + r * e.w) / tw, (best.rgb[1] * best.w + g * e.w) / tw, (best.rgb[2] * best.w + b * e.w) / tw];
|
||||
best.lab = rgbToLab(...best.rgb); best.w = tw;
|
||||
} else clusters.push({ rgb: [r, g, b], lab, w: e.w });
|
||||
}
|
||||
clusters.sort((a, b) => b.w - a.w);
|
||||
const top = clusters.slice(0, k);
|
||||
const covered = top.reduce((s, c) => s + c.w, 0) || 1;
|
||||
return top.map((c) => ({ hex: toHex(c.rgb), coverage: +(c.w / covered).toFixed(4), lab: c.lab }));
|
||||
}
|
||||
|
||||
export function toHex(rgb) {
|
||||
return '#' + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Palette match 0..1: for each dominant comp color, coverage-weighted best
|
||||
* Lab match in the build's dominant set (dE 0 -> 1, dE >= 40 -> 0).
|
||||
*/
|
||||
export function paletteMatch(compColors, buildColors) {
|
||||
if (!compColors.length) return 1;
|
||||
let s = 0, wsum = 0;
|
||||
for (const c of compColors) {
|
||||
let best = Infinity;
|
||||
for (const b of buildColors) best = Math.min(best, deltaE(c.lab, b.lab));
|
||||
s += c.coverage * Math.max(0, 1 - best / 40); wsum += c.coverage;
|
||||
}
|
||||
return wsum ? s / wsum : 1;
|
||||
}
|
||||
|
||||
/** Color score 0..1: blend of histogram intersection and dominant-palette match. */
|
||||
export function colorScore(imgA, imgB) {
|
||||
const inter = histogramIntersection(colorHistogram(imgA), colorHistogram(imgB));
|
||||
const pm = paletteMatch(dominantColors(imgA), dominantColors(imgB));
|
||||
return { score: 0.35 * inter + 0.65 * pm, intersection: inter, paletteMatch: pm };
|
||||
}
|
||||
|
||||
// ---- detail ----------------------------------------------------------------
|
||||
|
||||
/** Mean absolute gradient (Sobel-lite) per cell over a cols x rows grid. */
|
||||
export function detailGrid(img, cols = 12, rows = 8, workWidth = 512) {
|
||||
const h = Math.max(rows, Math.round((img.height / img.width) * workWidth));
|
||||
const g = toGray(resize(img, workWidth, h));
|
||||
const grid = new Float32Array(cols * rows);
|
||||
const counts = new Float32Array(cols * rows);
|
||||
for (let y = 1; y < g.height - 1; y++) {
|
||||
const cy = Math.min(rows - 1, Math.floor((y / g.height) * rows));
|
||||
for (let x = 1; x < g.width - 1; x++) {
|
||||
const cx = Math.min(cols - 1, Math.floor((x / g.width) * cols));
|
||||
const i = y * g.width + x;
|
||||
const gx = Math.abs(g.data[i + 1] - g.data[i - 1]);
|
||||
const gy = Math.abs(g.data[i + g.width] - g.data[i - g.width]);
|
||||
grid[cy * cols + cx] += gx + gy; counts[cy * cols + cx]++;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < grid.length; i++) grid[i] = counts[i] ? grid[i] / counts[i] : 0;
|
||||
return { cols, rows, cells: grid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail score 0..1 and per-cell ratio. Cells where the comp is nearly flat
|
||||
* are ignored (nothing to lose); the score is the coverage-weighted mean of
|
||||
* min(1, build/comp) over cells with comp energy, so extra detail in the
|
||||
* build (invented chrome) is reported separately as `added`.
|
||||
*/
|
||||
export function detailScore(imgA, imgB, cols = 12, rows = 8) {
|
||||
const a = detailGrid(imgA, cols, rows), b = detailGrid(imgB, cols, rows);
|
||||
const floor = 1.5; // energy below this is a flat field at the 512px working width
|
||||
let s = 0, w = 0, added = 0, addedW = 0;
|
||||
const ratios = new Float32Array(cols * rows);
|
||||
for (let i = 0; i < a.cells.length; i++) {
|
||||
const ca = a.cells[i], cb = b.cells[i];
|
||||
ratios[i] = ca > floor ? cb / ca : (cb > floor ? Infinity : 1);
|
||||
if (ca > floor) { s += Math.min(1, cb / ca) * ca; w += ca; }
|
||||
if (cb > ca * 1.8 && cb > floor * 2) { added += 1; }
|
||||
addedW += 1;
|
||||
}
|
||||
return { score: w ? s / w : 1, addedFraction: addedW ? added / addedW : 0, comp: a, build: b, ratios };
|
||||
}
|
||||
|
||||
// ---- pixel diff -----------------------------------------------------------
|
||||
|
||||
/** Per-pixel Lab-ish difference map (0..1) at a working width; blurred a little. */
|
||||
export function diffMap(imgA, imgB, workWidth = 384) {
|
||||
const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth));
|
||||
const a = resize(imgA, workWidth, h), b = resize(imgB, workWidth, h);
|
||||
const out = new Float32Array(workWidth * h);
|
||||
for (let i = 0, p = 0; i < out.length; i++, p += 4) {
|
||||
const dr = a.data[p] - b.data[p], dg = a.data[p + 1] - b.data[p + 1], db = a.data[p + 2] - b.data[p + 2];
|
||||
out[i] = Math.min(1, Math.sqrt(dr * dr + dg * dg + db * db) / 200);
|
||||
}
|
||||
return blurGray({ width: workWidth, height: h, data: out }, 1);
|
||||
}
|
||||
|
||||
// ---- bands (horizontal layout structure) ---------------------------------
|
||||
|
||||
/**
|
||||
* Detect horizontal band boundaries: rows where the mean color changes
|
||||
* sharply. Returns normalized y positions (0..1) with strengths. This is the
|
||||
* "layout grid" read of a page: header / hero / index / footer as bands.
|
||||
*/
|
||||
export function horizontalBands(img, workWidth = 128, minGap = 0.02) {
|
||||
const h = Math.max(16, Math.round((img.height / img.width) * workWidth));
|
||||
const s = resize(img, workWidth, h);
|
||||
const rowMean = new Float32Array(h * 3);
|
||||
for (let y = 0; y < h; y++) {
|
||||
let r = 0, g = 0, b = 0;
|
||||
for (let x = 0; x < workWidth; x++) { const p = (y * workWidth + x) * 4; r += s.data[p]; g += s.data[p + 1]; b += s.data[p + 2]; }
|
||||
rowMean[y * 3] = r / workWidth; rowMean[y * 3 + 1] = g / workWidth; rowMean[y * 3 + 2] = b / workWidth;
|
||||
}
|
||||
const edges = [];
|
||||
for (let y = 1; y < h; y++) {
|
||||
const d = Math.hypot(rowMean[y * 3] - rowMean[(y - 1) * 3], rowMean[y * 3 + 1] - rowMean[(y - 1) * 3 + 1], rowMean[y * 3 + 2] - rowMean[(y - 1) * 3 + 2]);
|
||||
if (d > 18) edges.push({ y: y / h, strength: Math.min(1, d / 120) });
|
||||
}
|
||||
// merge close edges
|
||||
const merged = [];
|
||||
for (const e of edges) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && e.y - last.y < minGap) { if (e.strength > last.strength) { last.y = e.y; last.strength = e.strength; } }
|
||||
else merged.push({ ...e });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Band agreement 0..1: fraction of comp bands with a build band within tolerance, and vice versa. */
|
||||
export function bandScore(bandsA, bandsB, tol = 0.04) {
|
||||
if (!bandsA.length && !bandsB.length) return 1;
|
||||
const match = (from, to) => from.filter((a) => to.some((b) => Math.abs(a.y - b.y) <= tol)).length;
|
||||
const recall = bandsA.length ? match(bandsA, bandsB) / bandsA.length : 1;
|
||||
const precision = bandsB.length ? match(bandsB, bandsA) / bandsB.length : 1;
|
||||
return 0.6 * recall + 0.4 * precision;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Dependency-free PNG decode/encode for the skill scripts.
|
||||
*
|
||||
* decodePng(buffer) -> { width, height, data } where data is RGBA8 (Uint8Array,
|
||||
* width*height*4). Handles every color type (0, 2, 3, 4, 6), bit depths 1-16
|
||||
* (16-bit is reduced to 8), all five filters, and Adam7 interlacing.
|
||||
*
|
||||
* encodePng({ width, height, data }) -> Buffer, RGBA8 in, 8-bit RGBA PNG out.
|
||||
*
|
||||
* Kept small on purpose: the skill scripts ship without npm dependencies, and
|
||||
* comps (gpt-image PNGs) and screenshots (Playwright / harness PNGs) are the
|
||||
* only formats the comp-fidelity tooling has to read.
|
||||
*/
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
const crcTable = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(data) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++) c = crcTable[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
export function isPng(buf) {
|
||||
return buf && buf.length > 8 && buf.subarray(0, 8).equals(SIGNATURE);
|
||||
}
|
||||
|
||||
function readChunks(buf) {
|
||||
const chunks = [];
|
||||
let pos = 8;
|
||||
while (pos + 8 <= buf.length) {
|
||||
const length = buf.readUInt32BE(pos);
|
||||
const type = buf.toString('latin1', pos + 4, pos + 8);
|
||||
const data = buf.subarray(pos + 8, pos + 8 + length);
|
||||
chunks.push({ type, data });
|
||||
pos += 12 + length;
|
||||
if (type === 'IEND') break;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
||||
|
||||
function paeth(a, b, c) {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
if (pb <= pc) return b;
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Unfilter one pass of scanlines in place; returns the raw (unfiltered) bytes. */
|
||||
function unfilter(raw, width, height, bpp, bitDepth, channels) {
|
||||
const stride = Math.ceil((width * channels * bitDepth) / 8);
|
||||
const out = new Uint8Array(stride * height);
|
||||
let inPos = 0;
|
||||
let prev = null;
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filter = raw[inPos++];
|
||||
const line = out.subarray(y * stride, (y + 1) * stride);
|
||||
line.set(raw.subarray(inPos, inPos + stride));
|
||||
inPos += stride;
|
||||
switch (filter) {
|
||||
case 0: break;
|
||||
case 1: for (let i = bpp; i < stride; i++) line[i] = (line[i] + line[i - bpp]) & 0xff; break;
|
||||
case 2: if (prev) for (let i = 0; i < stride; i++) line[i] = (line[i] + prev[i]) & 0xff; break;
|
||||
case 3:
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const left = i >= bpp ? line[i - bpp] : 0;
|
||||
const up = prev ? prev[i] : 0;
|
||||
line[i] = (line[i] + ((left + up) >> 1)) & 0xff;
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const left = i >= bpp ? line[i - bpp] : 0;
|
||||
const up = prev ? prev[i] : 0;
|
||||
const ul = prev && i >= bpp ? prev[i - bpp] : 0;
|
||||
line[i] = (line[i] + paeth(left, up, ul)) & 0xff;
|
||||
}
|
||||
break;
|
||||
default: throw new Error(`png: unknown filter ${filter} on row ${y}`);
|
||||
}
|
||||
prev = line;
|
||||
}
|
||||
return { bytes: out, stride, consumed: inPos };
|
||||
}
|
||||
|
||||
/** Read sample `index` (0-based across the row) from a packed scanline. */
|
||||
function sampleReader(bitDepth) {
|
||||
if (bitDepth === 8) return (line, i) => line[i];
|
||||
if (bitDepth === 16) return (line, i) => line[i * 2]; // high byte
|
||||
const perByte = 8 / bitDepth;
|
||||
const mask = (1 << bitDepth) - 1;
|
||||
const scale = 255 / mask;
|
||||
return (line, i) => {
|
||||
const byte = line[(i / perByte) | 0];
|
||||
const shift = 8 - bitDepth * ((i % perByte) + 1);
|
||||
return Math.round(((byte >> shift) & mask) * scale);
|
||||
};
|
||||
}
|
||||
|
||||
function writePixels(dst, dstWidth, bytes, stride, passWidth, passHeight, colorType, bitDepth, palette, trns, mapX, mapY) {
|
||||
const channels = CHANNELS[colorType];
|
||||
const read = sampleReader(bitDepth);
|
||||
const rawIndex = bitDepth < 8 ? (line, i) => {
|
||||
const perByte = 8 / bitDepth;
|
||||
const mask = (1 << bitDepth) - 1;
|
||||
const byte = line[(i / perByte) | 0];
|
||||
const shift = 8 - bitDepth * ((i % perByte) + 1);
|
||||
return (byte >> shift) & mask;
|
||||
} : read;
|
||||
for (let y = 0; y < passHeight; y++) {
|
||||
const line = bytes.subarray(y * stride, (y + 1) * stride);
|
||||
const dy = mapY(y);
|
||||
for (let x = 0; x < passWidth; x++) {
|
||||
const dx = mapX(x);
|
||||
const o = (dy * dstWidth + dx) * 4;
|
||||
let r, g, b, a = 255;
|
||||
switch (colorType) {
|
||||
case 0: {
|
||||
r = g = b = read(line, x);
|
||||
if (trns && trns.gray === rawIndex(line, x)) a = 0;
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
r = read(line, x * 3); g = read(line, x * 3 + 1); b = read(line, x * 3 + 2);
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
const idx = rawIndex(line, x);
|
||||
r = palette[idx * 3]; g = palette[idx * 3 + 1]; b = palette[idx * 3 + 2];
|
||||
if (trns && trns.alpha && idx < trns.alpha.length) a = trns.alpha[idx];
|
||||
break;
|
||||
}
|
||||
case 4: {
|
||||
r = g = b = read(line, x * 2); a = read(line, x * 2 + 1);
|
||||
break;
|
||||
}
|
||||
case 6: {
|
||||
r = read(line, x * 4); g = read(line, x * 4 + 1); b = read(line, x * 4 + 2); a = read(line, x * 4 + 3);
|
||||
break;
|
||||
}
|
||||
default: throw new Error(`png: unsupported color type ${colorType}`);
|
||||
}
|
||||
dst[o] = r; dst[o + 1] = g; dst[o + 2] = b; dst[o + 3] = a;
|
||||
}
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
export function decodePng(buf) {
|
||||
if (!isPng(buf)) throw new Error('png: not a PNG (bad signature)');
|
||||
const chunks = readChunks(buf);
|
||||
const ihdr = chunks.find((c) => c.type === 'IHDR');
|
||||
if (!ihdr) throw new Error('png: missing IHDR');
|
||||
const width = ihdr.data.readUInt32BE(0);
|
||||
const height = ihdr.data.readUInt32BE(4);
|
||||
const bitDepth = ihdr.data[8];
|
||||
const colorType = ihdr.data[9];
|
||||
const interlace = ihdr.data[12];
|
||||
const channels = CHANNELS[colorType];
|
||||
if (!channels) throw new Error(`png: unsupported color type ${colorType}`);
|
||||
const palChunk = chunks.find((c) => c.type === 'PLTE');
|
||||
const palette = palChunk ? palChunk.data : null;
|
||||
const trnsChunk = chunks.find((c) => c.type === 'tRNS');
|
||||
let trns = null;
|
||||
if (trnsChunk) {
|
||||
if (colorType === 3) trns = { alpha: trnsChunk.data };
|
||||
else if (colorType === 0) trns = { gray: trnsChunk.data.readUInt16BE(0) >> (bitDepth === 16 ? 8 : 0) };
|
||||
}
|
||||
const idat = Buffer.concat(chunks.filter((c) => c.type === 'IDAT').map((c) => c.data));
|
||||
const raw = zlib.inflateSync(idat);
|
||||
const bpp = Math.max(1, Math.ceil((channels * bitDepth) / 8));
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
const text = {};
|
||||
for (const c of chunks) {
|
||||
if (c.type === 'tEXt') {
|
||||
const z = c.data.indexOf(0);
|
||||
if (z > 0) text[c.data.toString('latin1', 0, z)] = c.data.toString('utf8', z + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (interlace === 0) {
|
||||
const { bytes, stride } = unfilter(raw, width, height, bpp, bitDepth, channels);
|
||||
writePixels(data, width, bytes, stride, width, height, colorType, bitDepth, palette, trns, (x) => x, (y) => y);
|
||||
} else {
|
||||
// Adam7
|
||||
const passes = [
|
||||
[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2],
|
||||
];
|
||||
let offset = 0;
|
||||
for (const [sx, sy, dx, dy] of passes) {
|
||||
const pw = Math.ceil((width - sx) / dx);
|
||||
const ph = Math.ceil((height - sy) / dy);
|
||||
if (pw <= 0 || ph <= 0) continue;
|
||||
const { bytes, stride, consumed } = unfilter(raw.subarray(offset), pw, ph, bpp, bitDepth, channels);
|
||||
offset += consumed;
|
||||
writePixels(data, width, bytes, stride, pw, ph, colorType, bitDepth, palette, trns, (x) => sx + x * dx, (y) => sy + y * dy);
|
||||
}
|
||||
}
|
||||
return { width, height, data, text };
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'latin1');
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||
return Buffer.concat([len, typeBuf, data, crc]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode RGBA8 to PNG. `text` (optional) is a map of tEXt keyword -> value.
|
||||
* Uses filter type 0 on every row: comps and screenshots compress fine and the
|
||||
* encoder stays trivial.
|
||||
*/
|
||||
export function encodePng({ width, height, data }, { text = null, level = 6 } = {}) {
|
||||
if (data.length !== width * height * 4) throw new Error(`png: data length ${data.length} != ${width}x${height}x4`);
|
||||
const stride = width * 4;
|
||||
const raw = Buffer.alloc((stride + 1) * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (stride + 1)] = 0;
|
||||
raw.set(data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||
const parts = [SIGNATURE, chunk('IHDR', ihdr)];
|
||||
if (text) {
|
||||
for (const [k, v] of Object.entries(text)) {
|
||||
parts.push(chunk('tEXt', Buffer.concat([Buffer.from(k, 'latin1'), Buffer.from([0]), Buffer.from(String(v), 'utf8')])));
|
||||
}
|
||||
}
|
||||
parts.push(chunk('IDAT', zlib.deflateSync(raw, { level })));
|
||||
parts.push(chunk('IEND', Buffer.alloc(0)));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Small RGBA raster toolkit shared by the comp-fidelity scripts: crop, resize
|
||||
* (area-averaging down, bilinear up), composite, fills, rectangles, and a
|
||||
* bitmap-font label so composites can be captioned without a font stack.
|
||||
*
|
||||
* An image is `{ width, height, data }` with RGBA8 data (Uint8Array).
|
||||
*/
|
||||
|
||||
export function createImage(width, height, fill = [0, 0, 0, 0]) {
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
if (fill[0] || fill[1] || fill[2] || fill[3]) {
|
||||
for (let i = 0; i < data.length; i += 4) { data[i] = fill[0]; data[i + 1] = fill[1]; data[i + 2] = fill[2]; data[i + 3] = fill[3]; }
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
export function clampRect(img, x, y, w, h) {
|
||||
const x0 = Math.max(0, Math.min(img.width, Math.round(x)));
|
||||
const y0 = Math.max(0, Math.min(img.height, Math.round(y)));
|
||||
const x1 = Math.max(x0, Math.min(img.width, Math.round(x + w)));
|
||||
const y1 = Math.max(y0, Math.min(img.height, Math.round(y + h)));
|
||||
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
||||
}
|
||||
|
||||
export function crop(img, x, y, w, h) {
|
||||
const r = clampRect(img, x, y, w, h);
|
||||
const out = createImage(Math.max(1, r.w), Math.max(1, r.h));
|
||||
for (let yy = 0; yy < r.h; yy++) {
|
||||
const src = ((r.y + yy) * img.width + r.x) * 4;
|
||||
out.data.set(img.data.subarray(src, src + r.w * 4), yy * out.width * 4);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Resize with area averaging when shrinking and bilinear when growing. */
|
||||
export function resize(img, width, height) {
|
||||
width = Math.max(1, Math.round(width));
|
||||
height = Math.max(1, Math.round(height));
|
||||
if (width === img.width && height === img.height) return { width, height, data: new Uint8Array(img.data) };
|
||||
const out = createImage(width, height);
|
||||
const sx = img.width / width, sy = img.height / height;
|
||||
if (sx >= 1 && sy >= 1) {
|
||||
for (let y = 0; y < height; y++) {
|
||||
const y0 = Math.floor(y * sy), y1 = Math.min(img.height, Math.max(y0 + 1, Math.floor((y + 1) * sy)));
|
||||
for (let x = 0; x < width; x++) {
|
||||
const x0 = Math.floor(x * sx), x1 = Math.min(img.width, Math.max(x0 + 1, Math.floor((x + 1) * sx)));
|
||||
let r = 0, g = 0, b = 0, a = 0, n = 0;
|
||||
for (let yy = y0; yy < y1; yy++) {
|
||||
let p = (yy * img.width + x0) * 4;
|
||||
for (let xx = x0; xx < x1; xx++, p += 4) { r += img.data[p]; g += img.data[p + 1]; b += img.data[p + 2]; a += img.data[p + 3]; n++; }
|
||||
}
|
||||
const o = (y * width + x) * 4;
|
||||
out.data[o] = r / n; out.data[o + 1] = g / n; out.data[o + 2] = b / n; out.data[o + 3] = a / n;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for (let y = 0; y < height; y++) {
|
||||
const fy = Math.min(img.height - 1, (y + 0.5) * sy - 0.5);
|
||||
const y0 = Math.max(0, Math.floor(fy)), y1 = Math.min(img.height - 1, y0 + 1), wy = fy - y0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const fx = Math.min(img.width - 1, (x + 0.5) * sx - 0.5);
|
||||
const x0 = Math.max(0, Math.floor(fx)), x1 = Math.min(img.width - 1, x0 + 1), wx = fx - x0;
|
||||
const o = (y * width + x) * 4;
|
||||
for (let c = 0; c < 4; c++) {
|
||||
const p00 = img.data[(y0 * img.width + x0) * 4 + c], p10 = img.data[(y0 * img.width + x1) * 4 + c];
|
||||
const p01 = img.data[(y1 * img.width + x0) * 4 + c], p11 = img.data[(y1 * img.width + x1) * 4 + c];
|
||||
out.data[o + c] = (p00 * (1 - wx) + p10 * wx) * (1 - wy) + (p01 * (1 - wx) + p11 * wx) * wy;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Scale to fit inside (maxW x maxH) preserving aspect; never upscale unless `allowUpscale`. */
|
||||
export function fit(img, maxW, maxH, allowUpscale = false) {
|
||||
const s = Math.min(maxW / img.width, maxH / img.height);
|
||||
if (s >= 1 && !allowUpscale) return img;
|
||||
return resize(img, img.width * s, img.height * s);
|
||||
}
|
||||
|
||||
/** Alpha-composite `src` onto `dst` at (x, y). */
|
||||
export function blit(dst, src, x, y) {
|
||||
x = Math.round(x); y = Math.round(y);
|
||||
for (let yy = 0; yy < src.height; yy++) {
|
||||
const dy = y + yy; if (dy < 0 || dy >= dst.height) continue;
|
||||
for (let xx = 0; xx < src.width; xx++) {
|
||||
const dx = x + xx; if (dx < 0 || dx >= dst.width) continue;
|
||||
const s = (yy * src.width + xx) * 4, d = (dy * dst.width + dx) * 4;
|
||||
const a = src.data[s + 3] / 255;
|
||||
if (a >= 1) { dst.data[d] = src.data[s]; dst.data[d + 1] = src.data[s + 1]; dst.data[d + 2] = src.data[s + 2]; dst.data[d + 3] = 255; continue; }
|
||||
if (a <= 0) continue;
|
||||
const da = dst.data[d + 3] / 255, oa = a + da * (1 - a);
|
||||
for (let c = 0; c < 3; c++) dst.data[d + c] = (src.data[s + c] * a + dst.data[d + c] * da * (1 - a)) / (oa || 1);
|
||||
dst.data[d + 3] = oa * 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function fillRect(img, x, y, w, h, rgba) {
|
||||
const r = clampRect(img, x, y, w, h);
|
||||
const a = (rgba[3] ?? 255) / 255;
|
||||
for (let yy = r.y; yy < r.y + r.h; yy++) {
|
||||
for (let xx = r.x; xx < r.x + r.w; xx++) {
|
||||
const o = (yy * img.width + xx) * 4;
|
||||
if (a >= 1) { img.data[o] = rgba[0]; img.data[o + 1] = rgba[1]; img.data[o + 2] = rgba[2]; img.data[o + 3] = 255; }
|
||||
else { for (let c = 0; c < 3; c++) img.data[o + c] = rgba[c] * a + img.data[o + c] * (1 - a); img.data[o + 3] = Math.max(img.data[o + 3], a * 255); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function strokeRect(img, x, y, w, h, rgba, thickness = 2) {
|
||||
fillRect(img, x, y, w, thickness, rgba);
|
||||
fillRect(img, x, y + h - thickness, w, thickness, rgba);
|
||||
fillRect(img, x, y, thickness, h, rgba);
|
||||
fillRect(img, x + w - thickness, y, thickness, h, rgba);
|
||||
}
|
||||
|
||||
// 5x7 bitmap font, uppercase + digits + a little punctuation. Enough for labels.
|
||||
const GLYPHS = {
|
||||
A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
|
||||
C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
|
||||
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
|
||||
E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
|
||||
F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
|
||||
G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
|
||||
H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
|
||||
I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
|
||||
J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
|
||||
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
|
||||
L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
|
||||
M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
|
||||
N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'],
|
||||
O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
|
||||
Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
|
||||
R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
|
||||
S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
|
||||
T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
|
||||
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
|
||||
V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
|
||||
W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'],
|
||||
X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
|
||||
Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
|
||||
Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
|
||||
0: ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
|
||||
1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
|
||||
2: ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
|
||||
3: ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
|
||||
4: ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
|
||||
5: ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
|
||||
6: ['00110', '01000', '10000', '11110', '10001', '10001', '01110'],
|
||||
7: ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
|
||||
8: ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
|
||||
9: ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
|
||||
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
|
||||
'.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'],
|
||||
':': ['00000', '01100', '01100', '00000', '01100', '01100', '00000'],
|
||||
'-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
|
||||
'/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'],
|
||||
'%': ['11001', '11010', '00010', '00100', '01000', '01011', '10011'],
|
||||
'(': ['00010', '00100', '01000', '01000', '01000', '00100', '00010'],
|
||||
')': ['01000', '00100', '00010', '00010', '00010', '00100', '01000'],
|
||||
'#': ['01010', '01010', '11111', '01010', '11111', '01010', '01010'],
|
||||
'_': ['00000', '00000', '00000', '00000', '00000', '00000', '11111'],
|
||||
'?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'],
|
||||
'=': ['00000', '00000', '11111', '00000', '11111', '00000', '00000'],
|
||||
'+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'],
|
||||
',': ['00000', '00000', '00000', '00000', '01100', '00100', '01000'],
|
||||
};
|
||||
|
||||
export function textWidth(text, scale = 2) {
|
||||
return text.length * 6 * scale;
|
||||
}
|
||||
|
||||
/** Draw uppercase bitmap text. Returns width drawn. */
|
||||
export function drawText(img, text, x, y, rgba, scale = 2) {
|
||||
let cx = Math.round(x);
|
||||
for (const chRaw of String(text).toUpperCase()) {
|
||||
const g = GLYPHS[chRaw] || GLYPHS['?'];
|
||||
for (let r = 0; r < 7; r++) for (let c = 0; c < 5; c++) if (g[r][c] === '1') fillRect(img, cx + c * scale, y + r * scale, scale, scale, rgba);
|
||||
cx += 6 * scale;
|
||||
}
|
||||
return cx - x;
|
||||
}
|
||||
|
||||
/** Draw a label with a background pill. */
|
||||
export function drawLabel(img, text, x, y, { fg = [255, 255, 255, 255], bg = [0, 0, 0, 220], scale = 2, pad = 4 } = {}) {
|
||||
const w = textWidth(text, scale) + pad * 2, h = 7 * scale + pad * 2;
|
||||
fillRect(img, x, y, w, h, bg);
|
||||
drawText(img, text, x + pad, y + pad, fg, scale);
|
||||
return { w, h };
|
||||
}
|
||||
Reference in New Issue
Block a user