bbc64118a5
- 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)
92 lines
4.0 KiB
JavaScript
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');
|