Files
pbakaus_impeccable/skill/scripts/lib/raster.mjs
T
b0fc2e8801 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>
2026-08-28 06:09:46 +05:00

195 lines
9.6 KiB
JavaScript

/**
* 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 };
}