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:
2026-10-17 11:01:00 +07:00
parent 2761d4f5a3
commit bbc64118a5
14 changed files with 1242 additions and 415 deletions
+8
View File
@@ -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 \
+2 -1
View File
@@ -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",
+101 -20
View File
@@ -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;
+92
View File
@@ -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');
+4 -3
View File
@@ -59,15 +59,16 @@ assert.match(engine, /drawImageRect\(cutImage, box, onto, /, 'the fill is drawn
// answer is written into the fill surface over the box, and the mask it is
// handed is built from the brush's own circles. What it is NOT is measured
// here — a working model is invisible to the numbers below, which are the
// fallback's. The polarity is MI-GAN's, 255 KEEP and 0 HOLE, and it is the one
// fallback's. The polarity is the model's, 255 HOLE and 0 KEEP, and it is the one
// thing that, got backwards, repaints the frame and leaves the hole (measured in
// engine/inpaint.ts; the probe is gone).
const inpaint = readFileSync(new URL('../src/engine/inpaint.ts', import.meta.url), 'utf8');
assert.match(engine, /await inpaintCrop\(cropPixels, mw, mh, hole\)/, 'exportEngine does not ask the model for the masked crop');
assert.match(engine, /await inpaintCrop\(cropPixels, mw, mh, hole, brushArea \/ \(width \* height\)\)/, 'exportEngine does not ask the model for the masked crop, measured against the frame');
assert.match(engine, /patchSurface\.getCanvas\(\)\.writePixels\(patch, mw, mh, 0, 0\)/, 'the model answer is never written into the fill surface');
assert.match(engine, /new Uint8Array\(mw \* mh\)/, 'the mask handed to the model is not built at the crop\'s own resolution');
assert.match(engine, /hole\[row \+ x\] = 1/, 'the mask handed to the model is not the brush\'s own circles');
assert.match(inpaint, /fill\(255\)/, 'the model mask no longer starts at KEEP');
assert.match(inpaint, /const mask = new Uint8Array\(plane\)/, 'the model mask no longer starts at KEEP');
assert.match(inpaint, /mask\[d\] = 255/, 'the brush\'s circles are no longer marked as holes in the model mask');
assert.match(inpaint, /INPAINT_MAX_SIDE/, 'the crop handed to the model is no longer bounded in size');
assert.match(inpaint, /inpaintCropSize/, 'nothing tells the renderer the resolution the model will run at');
@@ -0,0 +1,253 @@
// The hybrid's three client-side rules, measured on synthetic frames whose answer is known:
//
// * the router — which of the two engines a mask goes to (a speck of dust on open ground is the
// fast engine's job and costs nothing; an object, or a mark that sits across a real edge,
// goes to the model),
// * the mask the model is given — grown a few pixels over the stroke so the object's shadow
// goes with it, and only over the stroke, because the brush is not the wound,
// * the grain the model leaves behind — a model patch is a clean surface, and a clean surface
// in a photograph of sensor noise reads as a smudge, so it is given back the ground's own
// grain and nothing where the ground has none.
//
// node scripts/heal-route-check.mjs
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('../src/engine/inpaint.ts', import.meta.url);
const dir = mkdtempSync(join(tmpdir(), 'heal-route-'));
writeFileSync(
join(dir, 'inpaint.mjs'),
ts.transpileModule(readFileSync(SRC, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText
);
const { aiTierNeeded, dilateMask, matchGrain, AI_MAX_MASK_AREA, fastLocalInpaint, localFillWasFlat } = await import(
pathToFileURL(join(dir, 'inpaint.mjs')).href
);
const W = 512, H = 512;
const luma = (v) => Math.max(0, Math.min(255, v));
// A frame builder: `at(x, y)` says what the ground looks like, and the hole is a disc on it.
function frame(at, { cx = 256, cy = 256, r = 12, noise = 0, seed = 7 } = {}) {
const rgba = new Uint8Array(W * H * 4);
const hole = new Uint8Array(W * H);
let s = seed;
const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff) - 0.5;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = y * W + x, p = i * 4;
const v = luma(at(x, y) + (noise ? rnd() * noise : 0));
rgba[p] = rgba[p + 1] = rgba[p + 2] = v;
rgba[p + 3] = 255;
if (r > 0 && (x - cx) ** 2 + (y - cy) ** 2 <= r * r) hole[i] = 1;
}
}
return { rgba, hole };
}
const flat = () => 128;
const steps = (x) => (x < 128 || x > 380 ? 80 : 176);
const grain = () => 128;
const FRAME_AREA = W * H; // this frame is the whole picture for the routing tests
// 1. A speck of dust on open ground: the fast engine, no server.
const speck = frame(flat, { r: 12 });
assert.equal(aiTierNeeded(speck.rgba, W, H, speck.hole, (Math.PI * 12 * 12) / FRAME_AREA), false);
// 2. Something the size of an object: the model. On this 512x512 frame a 60px radius is 4.3% of
// the area, which is the same order as a 5%-of-width brush on a 3:2 frame.
const object = frame(flat, { r: 60 });
assert.equal(aiTierNeeded(object.rgba, W, H, object.hole, (Math.PI * 60 * 60) / FRAME_AREA), true);
assert.ok(
(Math.PI * 60 * 60) / FRAME_AREA > AI_MAX_MASK_AREA,
'the object case must be over the 3.5% the chart draws'
);
// 2b. The brush at its widest on open ground is still a speck by that measure: the fast engine.
const wideDab = frame(flat, { r: 26 });
assert.equal(aiTierNeeded(wideDab.rgba, W, H, wideDab.hole, (Math.PI * 26 * 26) / FRAME_AREA), false);
// 2c. With no frame to judge the area against, the window is what is left — and a real window is
// a tight one around its hole, so a mark that fills an eighth of it goes to the model.
const filled = frame(flat, { r: 100 });
assert.equal(aiTierNeeded(filled.rgba, W, H, filled.hole), true);
// 3. A speck, but one sitting across a real edge: the model, however small it is. The ground here
// is quiet (rim 1.88 of 255 either side of the line) so the area test would have kept it home;
// what sends it out is that the rim carries six times the window's own structure.
const onEdge = frame(steps, { cx: 128, cy: 256, r: 12 });
assert.equal(aiTierNeeded(onEdge.rgba, W, H, onEdge.hole, (Math.PI * 12 * 12) / FRAME_AREA), true);
// 3b. Busy ground — foliage, water ripples, a crowd — is where a copied patch shows up as a slab
// of somewhere else, so a speck there goes to the model too, even with no edge in sight.
const foliage = frame((x, y) => 120 + 50 * Math.abs(Math.sin(x * 0.21 + y * 0.13)) + 30 * Math.sin(x * 0.7 + y * 1.3), { r: 26 });
assert.equal(aiTierNeeded(foliage.rgba, W, H, foliage.hole, (Math.PI * 26 * 26) / FRAME_AREA), true);
// 3c. ...but ripples are not that busy, and the engine does answer them: a speck on water stays.
const water = frame((x, y) => 128 + 18 * Math.sin(x * 0.35 + y * 0.5) + 10 * Math.sin(x * 0.9 - y * 0.7), { r: 26 });
assert.equal(aiTierNeeded(water.rgba, W, H, water.hole, (Math.PI * 26 * 26) / FRAME_AREA), false);
// 4. A speck on ground that is nothing but sensor grain: the fast engine. Grain is not structure.
const noisy = frame(grain, { r: 12, noise: 14 });
assert.equal(aiTierNeeded(noisy.rgba, W, H, noisy.hole, (Math.PI * 12 * 12) / FRAME_AREA), false);
// ...and the same picture without the noise reads the same way, so noise is not what decided it.
const clean = frame(grain, { r: 12 });
assert.equal(aiTierNeeded(clean.rgba, W, H, clean.hole, (Math.PI * 12 * 12) / FRAME_AREA), false);
// 5. The mask handed to the model: grown by exactly the radius, and no further. The growth is a
// box, so a pixel may sit up to r diagonally away — but never outside the r-wide slab.
const grown = dilateMask(speck.hole, W, H, 3);
let grew = 0, shrank = 0, tooFar = 0;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = y * W + x;
if (!speck.hole[i] && grown[i]) {
grew++;
const d = Math.hypot(x - 256, y - 256);
if (d > 12 + 3 * Math.SQRT2 + 0.5) tooFar++;
}
if (speck.hole[i] && !grown[i]) shrank++;
}
}
assert.equal(shrank, 0, 'a grown mask still covers the stroke');
assert.ok(grew > 200, `the mask must actually grow (a 3px ring on a 12px disc is ~250 pixels, got ${grew})`);
assert.equal(tooFar, 0, 'and not by more than the radius asked for');
// 6. Grain handed back. The patch is what a model returns — a smooth surface — and the ground
// under the same mask is the photo's own noise. After the call the patch's high-pass may no
// longer be a fraction of the ground's, and the pixels outside the mask are untouched.
const noisyGround = frame(grain, { r: 26, noise: 16 });
const smoothPatch = new Uint8Array(4 * W * H);
for (let i = 0; i < W * H; i++) {
const p = i * 4;
smoothPatch[p] = smoothPatch[p + 1] = smoothPatch[p + 2] = 128;
smoothPatch[p + 3] = 255;
}
const before = smoothPatch.slice();
const hp = (data, mask, inside, stride) => {
let sum = 0, sum2 = 0, n = 0;
for (let y = 1; y < H - 1; y += stride) {
for (let x = 1; x < W - 1; x += stride) {
const i = y * W + x;
if (inside ? !mask[i] : mask[i]) continue;
const m = (data[i - W - 1] + data[i - W] + data[i - W + 1] + data[i - 1] + data[i] + data[i + 1] + data[i + W - 1] + data[i + W] + data[i + W + 1]) / 9;
const d = data[i] - m;
sum += d;
sum2 += d * d;
n++;
}
}
return Math.sqrt(Math.max(0, sum2 / n - (sum / n) ** 2));
};
const groundPlane = new Uint8Array(W * H);
for (let i = 0; i < W * H; i++) groundPlane[i] = noisyGround.rgba[i * 4];
// matchGrain reads the source the way the server is fed it: three planes, no alpha.
const src3 = new Uint8Array(3 * W * H);
for (let i = 0; i < W * H; i++) {
src3[i] = noisyGround.rgba[i * 4];
src3[W * H + i] = noisyGround.rgba[i * 4 + 1];
src3[2 * W * H + i] = noisyGround.rgba[i * 4 + 2];
}
const patchPlane = new Uint8Array(W * H);
for (let i = 0; i < W * H; i++) patchPlane[i] = smoothPatch[i * 4];
const groundSigma = hp(groundPlane, noisyGround.hole, false, 1);
const smoothSigma = hp(patchPlane, noisyGround.hole, true, 2);
const added = matchGrain(smoothPatch, src3, noisyGround.hole, W, H);
for (let i = 0; i < W * H; i++) patchPlane[i] = smoothPatch[i * 4];
const nowSigma = hp(patchPlane, noisyGround.hole, true, 2);
let touchedOutside = 0;
for (let i = 0; i < W * H; i++) {
if (noisyGround.hole[i]) continue;
const p = i * 4;
if (smoothPatch[p] !== before[p]) touchedOutside++;
}
console.log(
`ground hp sigma ${groundSigma.toFixed(2)}, model patch ${smoothSigma.toFixed(2)}, ` +
`after grain ${nowSigma.toFixed(2)} (added ${added.toFixed(2)})`
);
assert.ok(groundSigma > 3, 'the synthetic ground has to be noisy for this to mean anything');
assert.ok(nowSigma > groundSigma * 0.5, 'the model patch must end up carrying the ground grain');
assert.ok(nowSigma < groundSigma * 2.2, 'and not more of it than the ground has');
assert.equal(touchedOutside, 0, 'grain goes under the mask only');
// 7. ...and a patch that already has the ground's grain is not given a second helping.
const already = noisyGround.rgba.slice();
const addedAgain = matchGrain(already, src3, noisyGround.hole, W, H);
console.log(`already-grained patch, added ${addedAgain.toFixed(2)}`);
assert.ok(addedAgain < 1.5, `a grained patch is left alone (added ${addedAgain.toFixed(2)})`);
// 8. The fourth routing rule, the one only the engine knows: a mask no translation of the photo
// can clear. There is no patch to copy, so the mask is the model's whatever its area — and
// what the fast engine lays down instead has to be the smooth field of its own rim, with no
// direction in it. That is what the fan (radial rays converging on the centre) and the
// repeated stamp of a walked translation both are, and neither may come back from here.
const wide = frame(flat, { r: 250 }); // wider than half the crop in every direction: no shift clears it
// The mark the brush left behind, so "rebuilt" can be told from "left standing": everything under
// the brush is painted dark before the fill is asked for.
const marked = frame(flat, { r: 250 });
for (let i = 0; i < W * H; i++) {
if (!marked.hole[i]) continue;
const p = i * 4;
marked.rgba[p] = marked.rgba[p + 1] = marked.rgba[p + 2] = 30;
}
const wideFill = fastLocalInpaint(marked.rgba, W, H, marked.hole);
assert.equal(localFillWasFlat(), true, 'a hole that no translation clears must report no donor');
assert.equal(localFillWasFlat(), true, 'the flag has to survive the call the router reads it after');
let leftStanding = 0, farFromRim = 0;
for (let i = 0; i < W * H; i++) {
if (!marked.hole[i]) continue;
if (wideFill[i * 4] <= 31) leftStanding++;
if (Math.abs(wideFill[i * 4] - 128) > 60) farFromRim++;
}
assert.equal(leftStanding, 0, `${leftStanding} pixels under the brush still carry the mark`);
assert.equal(farFromRim, 0, `${farFromRim} filled pixels land away from the ground's own tone`);
// ...and the fill itself, on a ground with structure in it. What is asked of it is not accuracy
// — a hole this size is the model's, and SOR on a 500px disc converges short of the rim's own
// gradient, which is a smooth flattening and not an artifact — but that it invents no structure:
// it stays monotone across a ramp (no ridges, no crumpled planes) and it carries no fan.
const ramp = frame((x) => 30 + x * 0.4, { r: 250 });
const rampFill = fastLocalInpaint(ramp.rgba, W, H, ramp.hole);
let worstRamp = 0, drops = 0, prev = null;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (!ramp.hole[y * W + x]) continue;
const got = rampFill[(y * W + x) * 4];
worstRamp = Math.max(worstRamp, Math.abs(got - luma(30 + x * 0.4)));
if (prev !== null && got < prev - 1) drops++;
prev = got;
}
prev = null;
}
console.log(`wide ramp fill: worst ${worstRamp.toFixed(1)} levels off, ${drops} non-monotone pixels`);
assert.equal(drops, 0, `the flat fill must not ripple across a ramp (${drops} pixels go backwards)`);
assert.ok(worstRamp < 40, `the residual of an unconverged wide solve stays a flattening (${worstRamp.toFixed(1)} levels)`);
const striped = frame((x, y) => 128 + Math.sin(y * 0.6) * 24, { r: 250 });
const stripedFill = fastLocalInpaint(striped.rgba, W, H, striped.hole);
const bins = new Array(72).fill(0), cnt = new Array(72).fill(0);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = y * W + x;
if (!striped.hole[i]) continue;
const dx = x - 256, dy = y - 256;
const d = Math.hypot(dx, dy) / 250;
if (d < 0.25 || d > 0.8) continue;
const a = Math.round(((Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI)) * 72) % 72;
bins[a] += stripedFill[i * 4];
cnt[a]++;
}
}
const prof = bins.filter((_, i) => cnt[i]).map((v, i) => v / cnt[i]);
const pm = prof.reduce((a, b) => a + b, 0) / prof.length;
const fan = (100 * Math.sqrt(prof.reduce((a, b) => a + (b - pm) ** 2, 0) / prof.length)) / pm;
console.log(`wide striped fill: angular spread ${fan.toFixed(1)}% of the mean (the fan measure)`);
assert.ok(fan < 3, `a wide hole comes back without fan sectors (${fan.toFixed(1)}% — the old fill ran 9-39%)`);
console.log('PASS heal-route-check');
@@ -0,0 +1,91 @@
// 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');
+1 -1
View File
@@ -519,7 +519,7 @@ export function Workspace() {
const [brushTool, setBrushTool] = useState<'heal' | 'mosaic' | null>(null);
const [healR, setHealR] = useState(HEAL_DEFAULT_R);
const [mosaicR, setMosaicR] = useState(MOSAIC_DEFAULT_R);
const [aiInpaint, setAiInpaint] = useState<boolean>(() => recall('recipescam.heal.ai') === 'true');
const [aiInpaint, setAiInpaint] = useState<boolean>(() => recall('recipescam.heal.ai') !== 'false');
useEffect(() => {
setAiInpaintMode(aiInpaint);
keep('recipescam.heal.ai', String(aiInpaint));
+19 -8
View File
@@ -1079,13 +1079,20 @@ async function applyHealToBase(
const cSpanW = mx1 - mx0;
const cSpanH = my1 - my0;
const cSpan = Math.max(cSpanW, cSpanH);
const margin = Math.max(48, Math.min(192, Math.round(Math.max(cWidest * 2.5, cSpan * 0.4) + 24)));
const srcLeft = Math.max(boxLeft, Math.floor(mx0 - margin));
const srcTop = Math.max(boxTop, Math.floor(my0 - margin));
const srcRight = Math.min(boxRight, Math.ceil(mx1 + margin));
const srcBottom = Math.min(boxBottom, Math.ceil(my1 + margin));
const srcW = srcRight - srcLeft;
const srcH = srcBottom - srcTop;
// Provide 60%–80% context padding (min 64px) around the cluster with dimensions divisible by 8
// so both the Fast engine and LaMa ONNX have ample surrounding context without boundary distortion.
const padX = Math.max(64, Math.round(cSpanW * 0.75));
const padY = Math.max(64, Math.round(cSpanH * 0.75));
let srcLeft = Math.max(boxLeft, Math.floor(mx0 - padX));
let srcTop = Math.max(boxTop, Math.floor(my0 - padY));
let srcRight = Math.min(boxRight, Math.ceil(mx1 + padX));
let srcBottom = Math.min(boxBottom, Math.ceil(my1 + padY));
let srcW = srcRight - srcLeft;
let srcH = srcBottom - srcTop;
srcW = Math.max(8, srcW - (srcW % 8));
srcH = Math.max(8, srcH - (srcH % 8));
if (srcLeft + srcW > boxRight) srcLeft = Math.max(boxLeft, boxRight - srcW);
if (srcTop + srcH > boxBottom) srcTop = Math.max(boxTop, boxBottom - srcH);
if (srcW >= 8 && srcH >= 8) {
const { w: mw, h: mh } = inpaintCropSize(srcW, srcH);
@@ -1128,7 +1135,11 @@ async function applyHealToBase(
}
const patchKey = `${mw}x${mh}_` + cluster.map((s) => `${s.x.toFixed(4)},${s.y.toFixed(4)},${s.r.toFixed(4)}`).join(';');
(hole as { patchKey?: string }).patchKey = patchKey;
const patch = await inpaintCrop(cropPixels, mw, mh, hole).catch(() => null);
// What the engine router is told: how much of the frame is under this cluster's
// brush. A crop is a tight window around its hole, so its own area says nothing
// about whether the mark is a speck or an object (see engine/inpaint.ts).
const brushArea = cluster.reduce((sum, s) => sum + Math.PI * (s.r * width) ** 2, 0);
const patch = await inpaintCrop(cropPixels, mw, mh, hole, brushArea / (width * height)).catch(() => null);
if (patch) {
const patchSurface = tempOwn(createSurface(mw, mh));
if (patchSurface && patchSurface.getCanvas().writePixels(patch, mw, mh, 0, 0)) {
File diff suppressed because it is too large Load Diff
+46
View File
@@ -3056,6 +3056,52 @@ form[data-key='immich-modal'] {
box-sizing: border-box !important;
border-right: none !important;
}
/* Two things a desktop column is and a phone strip is not. Its height is the
frame's (`.col` above), so a strip that holds one row of chips stood 46vh
tall with that row floating in the middle of the empty bar. And every chip
in it takes the column's own width (`.col .chip` above), which in a row is
the row's width read once per chip: each chip came out as wide as the whole
bar, so the strip was one chip wide and eight chips long, the rest of them
off-screen, and what the visitor saw of a chip was its label alone in an
empty pill. A chip here is as wide as its own label, and a strip as tall as
the chips it holds. */
.workspace.is-mobile .col,
.workspace.is-mobile .col-main,
.workspace.is-mobile .col-sub,
.workspace.is-mobile .col[data-col="slider"],
.workspace.is-mobile .col-create,
.workspace.is-mobile .col-slider {
height: auto !important;
}
.workspace.is-mobile .col .chip {
width: auto !important;
}
/* The roll, in the bar's own system rather than the stage's. The chip bar
insets itself by 8px, spaces its chips 6px apart, and a chip is a pill on
`--bg-elev`; the strip already sat on that same 8px edge, but it wore the
stage's square frame and floated in the stage's centred cluster, so the row
of frames read as a foreign row hovering above the bar instead of the bar's
own first line. Same gap, same pill, and anchored to the bottom: the roll
ends up the row above the toolbar, which is the row above the chip bar, with
the photo giving way above it. Frames are 44px so the eight of a folder fit
the 414px a phone has between the insets and the roll does not have to be
scrolled to be read. */
.workspace.is-mobile .studio-strip {
gap: 6px !important;
/* The strip is centred when it is narrower than the stage, and a centred row
starts where its own width leaves it — 18px in, against the bar's 8px. It
takes the whole width so its first frame lands on the bar's own inset. */
width: 100% !important;
margin-top: auto !important;
padding-bottom: 0 !important;
scroll-padding-inline: 8px !important;
}
.workspace.is-mobile .strip-frame {
width: 44px !important;
height: 44px !important;
border-radius: 999px !important;
background: var(--bg-elev) !important;
}
.workspace.is-mobile .col-main {
flex-direction: row !important;
align-items: center !important;
+5
View File
@@ -86,6 +86,11 @@
/* --- base elements ------------------------------------------------------ */
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
/* iOS Safari inflates the text of any block wider than the viewport (the phone's
chip strips are `width: max-content` under .col-main, so a selected chip can
blow up to ~3x and take the whole bar). Every size in this app is written in
px, so the boost is off: 100% keeps the sizes as declared. */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body {
background: var(--bg);
color: var(--text);
+11 -1
View File
@@ -1150,7 +1150,17 @@ export function ImageStage({
const selBox =
box && selSpot !== null && selSpot < brushSpots.length
? (() => {
const s = brushSpots[selSpot];
const targetStroke = brushSpots[selSpot]?.stroke;
let anchorSpot = brushSpots[selSpot];
if (typeof targetStroke === 'number') {
for (let i = 0; i < brushSpots.length; i++) {
const sp = brushSpots[i];
if (sp.stroke === targetStroke && sp.y < anchorSpot.y) {
anchorSpot = sp;
}
}
}
const s = anchorSpot;
const off = (s.r * box.width + 14 / view.s) * Math.SQRT1_2;
return {
left: `calc(${s.x * 100}% + ${off}px)`,
+1 -1
View File
@@ -1 +1 @@
6d5c70d163ece1a8cc3225dac50848fbbafd65820bcafbaac3ecb5cca7d07ca2 recipescam-images.tar.gz
7cd97bf92bc1bf2446e53c10ff3e486b94e9ce0f841ebf9ba6a6fbaf81ef79ae recipescam-images.tar.gz