// The hole sits in the busy half of the frame, and other places to take a donor from are a short // hop away in the calm half, at the same tone. What the repair has to take from the photo is the // texture it will sit among, so three things must hold: every pixel under the brush is rebuilt, // the repair carries the surrounding texture rather than a slab laid over it (a donor picked on // brightness and gradient alone lands a smooth dome in the middle of a ripple — the patch looks // right against the rim it touches and wrong against everything else), and the tone the repair // leaves behind is the photo's own, not the mark's. // // node scripts/heal-texture-check.mjs // INPAINT_SRC=/tmp/old-inpaint.ts node scripts/heal-texture-check.mjs (must fail on the old engine) import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import ts from 'typescript'; const SRC = new URL(process.env.INPAINT_SRC ?? '../src/engine/inpaint.ts', import.meta.url); const dir = mkdtempSync(join(tmpdir(), 'heal-texture-')); writeFileSync( join(dir, 'inpaint.mjs'), ts.transpileModule(readFileSync(SRC, 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, }).outputText ); const { fastLocalInpaint } = await import(pathToFileURL(join(dir, 'inpaint.mjs')).href); const W = 256, H = 192, CX = 170, CY = 96, R = 32; const BUSY_FROM = 96; // everything left of this column is calm, and it is only a hop away const truth = new Uint8Array(W * H * 4); const dirty = new Uint8Array(W * H * 4); const hole = new Uint8Array(W * H); const tone = (x, y) => 120 + 30 * Math.sin((x + y) * 0.05); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { const i = y * W + x; const busy = x >= BUSY_FROM ? 22 * Math.sin(x * 1.3 + y * 1.7) + 12 * Math.sin(x * 2.1 - y * 0.9) : 0; const v = Math.min(255, Math.max(0, tone(x, y) + busy)); for (const px of [truth, dirty]) { px[i * 4] = px[i * 4 + 1] = px[i * 4 + 2] = v; px[i * 4 + 3] = 255; } // the mark: a red the grey photo cannot produce, so a pixel still wearing it was never rebuilt if ((x - CX) ** 2 + (y - CY) ** 2 <= R * R) { hole[i] = 1; dirty[i * 4] = 200; dirty[i * 4 + 1] = 20; dirty[i * 4 + 2] = 20; } } } const luma = (px, i) => 0.299 * px[i * 4] + 0.587 * px[i * 4 + 1] + 0.114 * px[i * 4 + 2]; // High-pass energy: the grain and small structure, with the slow shading a patch match is // supposed to ignore taken out. const spread = (px, from, to) => { let s = 0, ss = 0, n = 0; for (let y = 1; y < H - 1; y++) { for (let x = 1; x < W - 1; x++) { const d = Math.hypot(x - CX, y - CY); if (d < from || d > to) continue; const i = y * W + x; const hp = luma(px, i) - (luma(px, i - 1) + luma(px, i + 1) + luma(px, i - W) + luma(px, i + W)) / 4; s += hp; ss += hp * hp; n++; } } return Math.sqrt(Math.max(0, ss / n - (s / n) ** 2)); }; const out = fastLocalInpaint(dirty, W, H, hole); let untouched = 0, sum = 0, n = 0, truthSum = 0; for (let i = 0; i < W * H; i++) { if (!hole[i]) continue; if (out[i * 4] === 200 && out[i * 4 + 1] === 20) untouched++; sum += luma(out, i); truthSum += luma(truth, i); n++; } const inner = spread(out, 0, R * 0.8), ring = spread(out, R * 1.3, R * 2.2); const ratio = inner / ring; const toneMiss = Math.abs(sum / n - truthSum / n); console.log(`{ untouched: ${untouched}, texture: ${ratio.toFixed(2)}, inner: ${inner.toFixed(2)}, ring: ${ring.toFixed(2)}, toneMiss: ${toneMiss.toFixed(2)} }`); assert.equal(untouched, 0, 'every pixel under the brush must be rebuilt'); assert.ok(ratio > 0.6, `the repair must carry the surrounding texture, not a dome (texture ${ratio.toFixed(2)})`); assert.ok(toneMiss < 3, `the repair must land on the photo's own tone (off by ${toneMiss.toFixed(2)})`); console.log('heal texture match: ok');