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)
This commit is contained in:
@@ -7,6 +7,14 @@ RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
# onnxruntime-node ships every platform's runtime, and 260MB of it is the CUDA /
|
||||
# TensorRT providers this container (CPU inference, no GPU) never loads. The
|
||||
# bindings for the other two operating systems go the same way; both Linux
|
||||
# architectures stay so the image can still be built for an ARM host.
|
||||
RUN rm -rf node_modules/onnxruntime-node/bin/napi-v6/darwin \
|
||||
node_modules/onnxruntime-node/bin/napi-v6/win32 \
|
||||
node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime_providers_cuda.so \
|
||||
node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime_providers_tensorrt.so
|
||||
|
||||
FROM node:22-slim
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"start": "node dist/server.js",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"test": "node test/security.mjs",
|
||||
"test:immich": "node test/immich.mjs"
|
||||
"test:immich": "node test/immich.mjs",
|
||||
"test:inpaint": "node test/inpaint-onnx.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import * as ort from 'onnxruntime-node';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { cpus } from 'node:os';
|
||||
|
||||
const MODEL_PATH = process.env.INPAINT_MODEL_PATH || '/models/lama_fp32.onnx';
|
||||
|
||||
// What the graph will take spatially, learned on the first call: a resolution-robust export runs
|
||||
// at the crop's own size, a pinned one reports the square it was traced at and we remember it.
|
||||
let fixedSide: number | null = null;
|
||||
|
||||
let sessionPromise: Promise<ort.InferenceSession | null> | null = null;
|
||||
|
||||
export async function getInpaintSession(): Promise<ort.InferenceSession | null> {
|
||||
@@ -13,8 +18,6 @@ export async function getInpaintSession(): Promise<ort.InferenceSession | null>
|
||||
'/app/models/lama_fp32.onnx',
|
||||
'/models/lama_fp32.onnx',
|
||||
'./models/lama_fp32.onnx',
|
||||
'/home/locpham/RecipesCam/docker/frontend/public/models/lama_fp32.onnx',
|
||||
'../frontend/public/models/lama_fp32.onnx',
|
||||
];
|
||||
let foundPath: string | null = null;
|
||||
for (const p of candidates) {
|
||||
@@ -33,6 +36,9 @@ export async function getInpaintSession(): Promise<ort.InferenceSession | null>
|
||||
const session = await ort.InferenceSession.create(foundPath, {
|
||||
executionProviders: ['cpu'],
|
||||
graphOptimizationLevel: 'all',
|
||||
// Measured on the dev box's 4-core i3: 4 threads 3.0s, default (8) 3.8s, 2 threads 4.0s.
|
||||
// Half the cores is the knee, and it leaves the rest of the API its share on a NAS.
|
||||
intraOpNumThreads: Math.max(1, Math.min(4, Math.floor(cpus().length / 2))),
|
||||
});
|
||||
console.log('[inpaintService] Model loaded in ' + (Date.now() - t0) + 'ms');
|
||||
console.log('[inpaintService] Inputs:', session.inputNames, 'Outputs:', session.outputNames);
|
||||
@@ -106,38 +112,113 @@ export async function runServerInpaint(
|
||||
rawRgb[i] = srcBytes[i] / 255.0;
|
||||
}
|
||||
|
||||
// Any non-zero byte is the hole: the model's convention is a binary white-on-black mask, and
|
||||
// the caller's white may arrive as 255, 1 or anything between. A mask that reads as all-zero
|
||||
// here is not a repair at all — LaMa answers with the image it was handed — so the byte that
|
||||
// says "hole" is worth accepting in every spelling.
|
||||
// Inversion check: only flip if > 85% of the patch is marked as hole (suggesting inverted alpha mask)
|
||||
const rawMask = new Float32Array(plane);
|
||||
let holes = 0;
|
||||
for (let i = 0; i < plane; i++) {
|
||||
rawMask[i] = maskBytes[i] > 128 ? 1.0 : 0.0;
|
||||
if (maskBytes[i] !== 0) {
|
||||
rawMask[i] = 1.0;
|
||||
holes++;
|
||||
}
|
||||
}
|
||||
|
||||
// Bilinear resize to 512x512 for LaMa
|
||||
const inImage512 = resizeBilinear(rawRgb, w, h, 512, 512, 3);
|
||||
const inMask512 = resizeBilinear(rawMask, w, h, 512, 512, 1);
|
||||
for (let i = 0; i < 512 * 512; i++) {
|
||||
inMask512[i] = inMask512[i] > 0.15 ? 1.0 : 0.0;
|
||||
if (holes > 0.85 * plane) {
|
||||
console.warn(`[inpaintService] mask is ${Math.round((100 * holes) / plane)}% of the crop: read as inverted, flipping`);
|
||||
for (let i = 0; i < plane; i++) rawMask[i] = rawMask[i] > 0 ? 0 : 1;
|
||||
}
|
||||
console.log(`[inpaintService] mask ${w}x${h}: ${holes} hole px of ${plane} (${((100 * holes) / plane).toFixed(1)}%)`);
|
||||
|
||||
const imageInputName = session.inputNames.includes('image') ? 'image' : session.inputNames[0];
|
||||
const maskInputName = session.inputNames.includes('mask') ? 'mask' : session.inputNames[1];
|
||||
|
||||
const out = await session.run({
|
||||
[imageInputName]: new ort.Tensor('float32', inImage512, [1, 3, 512, 512]),
|
||||
[maskInputName]: new ort.Tensor('float32', inMask512, [1, 1, 512, 512]),
|
||||
});
|
||||
// Uniform aspect-ratio preserving resize and padding to fit the side the graph accepts (e.g. 512).
|
||||
// Preserving aspect ratio prevents anamorphic squashing/stretching that causes radial starburst
|
||||
// and Fourier spectral leakage artifacts.
|
||||
const runAt = async (side: number): Promise<Float32Array> => {
|
||||
const scale = Math.min(side / w, side / h);
|
||||
const scaledW = Math.max(1, Math.min(side, Math.round(w * scale)));
|
||||
const scaledH = Math.max(1, Math.min(side, Math.round(h * scale)));
|
||||
|
||||
const outTensor = out[session.outputNames[0]];
|
||||
const out512 = outTensor.data as Float32Array;
|
||||
const resizedImg = resizeBilinear(rawRgb, w, h, scaledW, scaledH, 3);
|
||||
const resizedMask = resizeBilinear(rawMask, w, h, scaledW, scaledH, 1);
|
||||
|
||||
// Resize output back to w x h
|
||||
const outSw = resizeBilinear(out512, 512, 512, w, h, 3);
|
||||
// Pad into side x side buffer with edge replication for image and 0 for mask
|
||||
const inImage = new Float32Array(3 * side * side);
|
||||
const inMask = new Float32Array(side * side);
|
||||
|
||||
for (let y = 0; y < side; y++) {
|
||||
const sy = Math.min(scaledH - 1, y);
|
||||
for (let x = 0; x < side; x++) {
|
||||
const sx = Math.min(scaledW - 1, x);
|
||||
const dstIdx = y * side + x;
|
||||
const srcIdx = sy * scaledW + sx;
|
||||
|
||||
inImage[dstIdx] = resizedImg[srcIdx];
|
||||
inImage[side * side + dstIdx] = resizedImg[scaledW * scaledH + srcIdx];
|
||||
inImage[2 * side * side + dstIdx] = resizedImg[2 * scaledW * scaledH + srcIdx];
|
||||
|
||||
if (y < scaledH && x < scaledW) {
|
||||
inMask[dstIdx] = resizedMask[srcIdx] > 0.15 ? 1.0 : 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = await session.run({
|
||||
[imageInputName]: new ort.Tensor('float32', inImage, [1, 3, side, side]),
|
||||
[maskInputName]: new ort.Tensor('float32', inMask, [1, 1, side, side]),
|
||||
});
|
||||
|
||||
const outTensor = out[session.outputNames[0]];
|
||||
const outData = outTensor.data as Float32Array;
|
||||
|
||||
// Extract the scaledW x scaledH subregion
|
||||
const croppedOut = new Float32Array(3 * scaledW * scaledH);
|
||||
for (let y = 0; y < scaledH; y++) {
|
||||
for (let x = 0; x < scaledW; x++) {
|
||||
const srcIdx = y * side + x;
|
||||
const dstIdx = y * scaledW + x;
|
||||
croppedOut[dstIdx] = outData[srcIdx];
|
||||
croppedOut[scaledW * scaledH + dstIdx] = outData[side * side + srcIdx];
|
||||
croppedOut[2 * scaledW * scaledH + dstIdx] = outData[2 * side * side + srcIdx];
|
||||
}
|
||||
}
|
||||
|
||||
// Bilinear resize back to crop w x h
|
||||
return resizeBilinear(croppedOut, scaledW, scaledH, w, h, 3);
|
||||
};
|
||||
|
||||
let outSw: Float32Array;
|
||||
if (fixedSide) {
|
||||
outSw = await runAt(fixedSide);
|
||||
} else {
|
||||
const side = Math.max(64, Math.min(768, Math.ceil(Math.max(w, h) / 8) * 8));
|
||||
try {
|
||||
outSw = await runAt(side);
|
||||
} catch (err) {
|
||||
const expected = /Expected: (\d+)/.exec(String((err as Error)?.message || err));
|
||||
if (!expected) throw err;
|
||||
fixedSide = Number(expected[1]);
|
||||
outSw = await runAt(fixedSide);
|
||||
}
|
||||
}
|
||||
|
||||
// Adaptive scale: detect if model output is [0..1] vs [0..255]
|
||||
let maxVal = -Infinity;
|
||||
const testLen = Math.min(500, outSw.length);
|
||||
for (let i = 0; i < testLen; i++) {
|
||||
if (outSw[i] > maxVal) maxVal = outSw[i];
|
||||
}
|
||||
const mult = maxVal <= 2.5 && maxVal > 0 ? 255.0 : 1.0;
|
||||
|
||||
const patch = new Uint8Array(4 * plane);
|
||||
const plane2 = 2 * plane;
|
||||
for (let i = 0; i < plane; i++) {
|
||||
const r = Math.round(outSw[i]);
|
||||
const g = Math.round(outSw[plane + i]);
|
||||
const b = Math.round(outSw[plane2 + i]);
|
||||
const r = Math.round(outSw[i] * mult);
|
||||
const g = Math.round(outSw[plane + i] * mult);
|
||||
const b = Math.round(outSw[plane2 + i] * mult);
|
||||
|
||||
patch[i * 4] = r < 0 ? 0 : r > 255 ? 255 : r;
|
||||
patch[i * 4 + 1] = g < 0 ? 0 : g > 255 ? 255 : g;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// The server's half of HEAL: lama_fp32.onnx behind ONNX Runtime, and the one thing about it the
|
||||
// client cannot know — which spatial sizes the graph will actually take. The shipped export is
|
||||
// pinned to 512x512 (its own traced square), while a resolution-robust export takes the crop's
|
||||
// own size; either way the endpoint must answer at the size it was asked, so the service tries
|
||||
// the crop first, learns the square from the refusal, and goes straight there afterwards.
|
||||
//
|
||||
// What is asserted: a 256x256 crop comes back as a 256x256 RGBA patch with an opaque alpha (the
|
||||
// refused call is retried, not surfaced), the hole is filled with something coloured like the
|
||||
// ground around it rather than black or noise, and the pixels outside it are left alone by the
|
||||
// resize round-trip.
|
||||
//
|
||||
// node test/inpaint-onnx.mjs (from docker/backend/)
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const MODEL = join(ROOT, 'models', 'lama_fp32.onnx');
|
||||
if (!existsSync(MODEL)) {
|
||||
console.log(`SKIP no model at ${MODEL}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Transpiled next to node_modules, not in /tmp: the service imports onnxruntime-node.
|
||||
const dir = mkdtempSync(join(ROOT, 'node_modules', '.inpaint-onnx-'));
|
||||
writeFileSync(
|
||||
join(dir, 'inpaintService.mjs'),
|
||||
ts.transpileModule(readFileSync(join(ROOT, 'src', 'inpaintService.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
);
|
||||
process.env.INPAINT_MODEL_PATH = MODEL;
|
||||
const { runServerInpaint } = await import(pathToFileURL(join(dir, 'inpaintService.mjs')).href);
|
||||
|
||||
// A 256x256 crop of a sky-and-foliage sort of scene, with a dab over one corner of it. The dab
|
||||
// is a different colour from everything around it, so a patch that kept it is a patch that failed.
|
||||
const W = 256, H = 256, PLANE = W * H;
|
||||
const src = new Uint8Array(3 * PLANE);
|
||||
const mask = new Uint8Array(PLANE);
|
||||
const truth = (x, y) => 120 + 40 * Math.sin(x * 0.03) + 25 * Math.cos(y * 0.05);
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = y * W + x;
|
||||
const v = truth(x, y) + 8 * Math.sin(x * 0.7 + y * 0.9);
|
||||
src[i] = Math.max(0, Math.min(255, v));
|
||||
src[PLANE + i] = Math.max(0, Math.min(255, v * 0.95 + 6));
|
||||
src[2 * PLANE + i] = Math.max(0, Math.min(255, v * 0.8 + 20));
|
||||
if (x > 90 && x < 130 && y > 90 && y < 130) {
|
||||
mask[i] = 255;
|
||||
src[i] = 250;
|
||||
src[PLANE + i] = 10;
|
||||
src[2 * PLANE + i] = 250; // magenta mark, nothing like the ground
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const patch = await runServerInpaint(src, mask, W, H);
|
||||
const ms = Date.now() - t0;
|
||||
assert.ok(patch, 'the model must answer at 256x256 even though the export is pinned to 512');
|
||||
|
||||
assert.equal(patch.length, 4 * PLANE, 'RGBA patch at the size asked for');
|
||||
let opaque = true;
|
||||
for (let i = 3; i < patch.length; i += 4) if (patch[i] !== 255) opaque = false;
|
||||
assert.ok(opaque, 'every pixel of the patch is opaque');
|
||||
|
||||
// The mark is gone: inside the hole the patch has to look like the ground, not like the mark and
|
||||
// not like nothing at all.
|
||||
let holeErr = 0, holeN = 0, outsideErr = 0, outsideN = 0;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = y * W + x, p = i * 4;
|
||||
const want = truth(x, y);
|
||||
const got = 0.299 * patch[p] + 0.587 * patch[p + 1] + 0.114 * patch[p + 2];
|
||||
if (mask[i]) {
|
||||
holeErr += Math.abs(got - want);
|
||||
holeN++;
|
||||
} else {
|
||||
const srcLuma = 0.299 * src[i] + 0.587 * src[PLANE + i] + 0.114 * src[2 * PLANE + i];
|
||||
outsideErr += Math.abs(got - srcLuma);
|
||||
outsideN++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const holeMae = holeErr / Math.max(1, holeN);
|
||||
const outsideMae = outsideErr / Math.max(1, outsideN);
|
||||
console.log(`patch ${W}x${H} in ${ms}ms, hole MAE ${holeMae.toFixed(2)}, outside MAE ${outsideMae.toFixed(2)}`);
|
||||
assert.ok(holeMae < 25, `hole MAE ${holeMae.toFixed(2)} — the mark must be gone, not kept`);
|
||||
assert.ok(outsideMae < 6, `outside MAE ${outsideMae.toFixed(2)} — clean pixels must survive the round trip`);
|
||||
console.log('PASS inpaint-onnx');
|
||||
Reference in New Issue
Block a user