Files
RecipesCam/docker/frontend/scripts/heal-texture-check.mjs
T
3dtours bbc64118a5 fix(heal): upgrade inpaint architecture to hybrid 2-tier LaMa ONNX and fix starburst artifacts
- backend: preserve aspect ratio with edge-replicated square padding into 512x512 LaMa model
- backend: fix inverted mask threshold (>0.85*plane) and prune CUDA/TensorRT binaries for slim image
- frontend: enable server AI by default and route masks >3.5% or edge-crossing to LaMa Tier 2
- frontend: enforce 60%-80% context padding (min 64px) aligned to multiples of 8
- frontend: add 3-5px binary mask dilation and high-frequency sensor grain matching
- frontend: anchor deletion badge to topmost point of brush stroke
- build & export: updated docker containers and recipescam-images.tar.gz (357MB)
2026-10-17 11:01:00 +07:00

92 lines
4.0 KiB
JavaScript

// 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');