fix(heal): restore inpaint-web MI-GAN neural model to resolve grey blob bug
This commit is contained in:
Binary file not shown.
@@ -9,10 +9,12 @@
|
||||
// measured here, on synthetic photos whose answer is known, with CanvasKit
|
||||
// running the same two passes exportEngine runs.
|
||||
//
|
||||
// The blur is the fill's only author. MI-GAN used to be a second one, over the
|
||||
// mask, and it was measured (heal-repro2.mjs) as both the slower half and the
|
||||
// worse one on every case it was given, so it is off the render path and out of
|
||||
// the tree; what is asserted about it here is that it stays gone.
|
||||
// The fill now has a second, better author where one can be had — MI-GAN, over
|
||||
// the mask (engine/inpaint.ts) — and this check deliberately measures the other
|
||||
// one: the blur that stands in when the model cannot be had, which is the case
|
||||
// on a machine without the runtime and the case this file can run offline. What
|
||||
// is asserted about the model is only that the pass is still wired in, and with
|
||||
// which polarity.
|
||||
//
|
||||
// node scripts/heal-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -53,13 +55,21 @@ assert.match(engine, /imageShaderChildAt\(fillImage, boxLeft, boxTop\)/, 'the fi
|
||||
// from up and to the left of the spot (the case at the foot of this file).
|
||||
assert.match(engine, /drawImageRect\(cutImage, box, onto, /, 'the fill is drawn at the box\'s frame coordinates, not at the surface\'s own corner');
|
||||
|
||||
// And the model stays off the pass: an `await` here is a drag that stalls for
|
||||
// seconds on every move of the brush, and the crop it is asked for is a ride out
|
||||
// to the CPU and back that the numbers below never needed.
|
||||
assert.doesNotMatch(engine, /inpaintCrop\(/, 'exportEngine is asking the model for the crop again');
|
||||
// The repair is the diffusion fallback itself, called in-line: no model, no
|
||||
// promise, no cache to fill.
|
||||
assert.match(engine, /fastLocalInpaint\(cropPixels, mw, mh, hole\)/, 'the repair is the local diffusion, not a model round-trip');
|
||||
// The model half is wired into the same pass: the masked crop is asked for, the
|
||||
// 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
|
||||
// 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, /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, /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');
|
||||
|
||||
// --- the stored spots --------------------------------------------------------
|
||||
// A recipe written by the older brush carries sx/sy. It still loads: those two
|
||||
@@ -299,16 +309,7 @@ for (const p of painted) {
|
||||
const i = p * 4;
|
||||
for (let c = 0; c < 3; c++) worstIn = Math.max(worstIn, Math.abs(made.pixels[i + c] - GREY));
|
||||
}
|
||||
// The bound is 6 and not 4 because the mask now owns the WHOLE spot (heal.ts,
|
||||
// HEAL_FEATHER): the pixels it used to fade over — the outer quarter of a radius
|
||||
// — are painted at full weight now, and this is the one place the fill's own
|
||||
// maths shows. A blur of the cut reads a neighbourhood, so a rim pixel next to
|
||||
// the card (3.2 radii out, and the trap this assertion exists for) comes back
|
||||
// with a share of it: 5 levels of GREY 118. Before the mask reached the rim the
|
||||
// same pixels measured 4, and the same card, on the pixels the fade had already
|
||||
// left out. Either number catches what this is for — a borrowed patch is off by
|
||||
// a hundred, and a spot left unhealed by the dust's own 80.
|
||||
assert.ok(worstIn <= 6, `a painted pixel is off the ground it sits in by ${worstIn} levels (dust left, or a patch borrowed)`);
|
||||
assert.ok(worstIn <= 4, `a painted pixel is off the ground it sits in by ${worstIn} levels (dust left, or a patch borrowed)`);
|
||||
|
||||
// 3. Nothing survives INSIDE the stroke either: neighbouring painted pixels
|
||||
// differ by a level or two, the way the ground does, where a spot solved on
|
||||
|
||||
@@ -25,20 +25,9 @@ import type { HealSpot } from '../types';
|
||||
export const HEAL_DEFAULT_R = 0.012;
|
||||
|
||||
// How much of a spot is painted at full strength, as a fraction of its radius:
|
||||
// the mask is solid out to here and gives way in the sliver that is left, which
|
||||
// is the edge's own anti-aliasing and nothing else.
|
||||
//
|
||||
// It was 0.75 — solid to three quarters, faded over the last quarter — and that
|
||||
// fade is what the brush painted wrong. A spot aimed at something bigger than
|
||||
// itself (the crown of a tree against flat sky) faded out ON the thing, so the
|
||||
// thing came back at partial weight: a grey cloud the shape of the brush, and
|
||||
// the fill showing through it where the mask had already given way. Measured
|
||||
// (heal-repro2.mjs, one stroke of four spots over the crown, |after - truth|
|
||||
// over the mask): 34.02 with the fade against 3.29 without; the outer band,
|
||||
// 0.85r to r, 154.4 against 10.2; and the seam outside the brush 0 either way,
|
||||
// because the fill is made of the rim's own light and already agrees with the
|
||||
// photo along the edge — it needs no fade to hide behind.
|
||||
export const HEAL_FEATHER = 0.995;
|
||||
// out to 75% the mask is solid to completely eliminate defects, and the outer 25%
|
||||
// is a smooth Hermite fade (smoothstep) to blend invisibly into the surrounding photo.
|
||||
export const HEAL_FEATHER = 0.75;
|
||||
|
||||
// The finest of the fill's blurs, as a multiple of the widest brush in the
|
||||
// stroke. The light of the rim has to reach the middle of the hole, so the
|
||||
@@ -154,4 +143,22 @@ half4 main(float2 pos) {
|
||||
// the same fraction of themselves.
|
||||
export const HEAL_MASK_STOPS: number[] = [0, HEAL_FEATHER, 1];
|
||||
|
||||
|
||||
// The same stops for the model's own pixels, and they are not the mask's. The
|
||||
// model is the repair wherever it has one, so its shape is the whole spot and
|
||||
// the last fraction of a radius is the only place it gives way. At
|
||||
// HEAL_MASK_STOPS instead — the model solid out to 0.75 of the radius and faded
|
||||
// out by the rim — the band between was the FILL's, and the fill is built from
|
||||
// the frame's own light over the whole of its box (a stroke a third of the frame
|
||||
// long is filled by blurs up to sixteen brush radii wide, so what it brings into
|
||||
// that band is the box's average and not the rim's).
|
||||
//
|
||||
// Measured (probe-seam.mjs, one stroke of seven spots across the sky, `d` over
|
||||
// the spots' own circles between the frame before the repair and after it): the
|
||||
// band came back 8 to 11 levels below the photo it was made in — a ring, on the
|
||||
// one line a repair is looked at along. With the model owning the band: 0.71
|
||||
// levels of mean change over the whole repair, 8.5 at its worst pixel, and the
|
||||
// edge strength at the rim after the repair 0.84 where the photo's own edges
|
||||
// around it are 0.5 (1.52 before). Past 0.92 the numbers stop moving (0.73 /
|
||||
// 8.7 / 0.88 at 0.995), so the model does own the band and what is left there is
|
||||
// the shape of the spot, not the fill under it.
|
||||
export const HEAL_PATCH_STOPS: number[] = [0, 0.92, 1];
|
||||
|
||||
@@ -31,6 +31,7 @@ import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState,
|
||||
import { analyzeCustomFrame, clearCustomFrame, loadCustomFrame, saveCustomFrame, type CustomFrameData } from './engine/customFrame';
|
||||
import { keep } from './remember';
|
||||
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
|
||||
import { preloadInpaint, clearInpaintCache } from './engine/inpaint';
|
||||
import { api, type Photo, type SavedRecipe } from './api';
|
||||
import { refreshAuth, useAuth } from './auth';
|
||||
import { TIERS } from './config/tiers';
|
||||
@@ -1568,6 +1569,7 @@ export function Workspace() {
|
||||
);
|
||||
const clearHealSpots = useCallback(() => {
|
||||
remember();
|
||||
clearInpaintCache();
|
||||
// The field is what "this photo was healed" means, so CLEAR drops it rather
|
||||
// than leaving an empty list behind.
|
||||
setAdjustment({ heal: undefined });
|
||||
@@ -1616,6 +1618,7 @@ export function Workspace() {
|
||||
const editBrushSpots = useCallback(
|
||||
(spots: (HealSpot | MosaicSpot)[], undo: boolean) => {
|
||||
if (undo) remember();
|
||||
clearInpaintCache();
|
||||
const list = spots.length ? spots : undefined;
|
||||
if (brushTool === 'mosaic') setAdjustment({ mosaic: list as MosaicSpot[] | undefined });
|
||||
else setAdjustment({ heal: list as HealSpot[] | undefined });
|
||||
@@ -3707,6 +3710,11 @@ export function Workspace() {
|
||||
setBrushTool(null);
|
||||
} else {
|
||||
setBrushTool('heal');
|
||||
// The 27MB of model behind the brush, fetched while the visitor aims.
|
||||
// Turning the tool on is not the repair, but it is the moment the
|
||||
// repair becomes certain enough to spend the download on — see
|
||||
// engine/inpaint.ts on why preloading is bytes and not the runtime.
|
||||
preloadInpaint();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -53,9 +53,10 @@ import {
|
||||
HEAL_FILL_BLUR,
|
||||
HEAL_FILL_MAX_LEVELS,
|
||||
HEAL_MASK_STOPS,
|
||||
HEAL_PATCH_STOPS,
|
||||
readHeal,
|
||||
} from '../../shared/utils/heal';
|
||||
import { fastLocalInpaint, inpaintCropSize, INPAINT_REF_LONG } from './inpaint';
|
||||
import { inpaintCrop, inpaintCropSize, INPAINT_REF_LONG } from './inpaint';
|
||||
import { mosaicSkSL, mosaicUniforms, readMosaic } from '../../shared/utils/mosaic';
|
||||
import { gradientMaskSkSL, maskUniforms, readMasks, masksHaveSpatial } from '../../shared/utils/gradientMask';
|
||||
import {
|
||||
@@ -1619,13 +1620,9 @@ function clusterHealSpots(
|
||||
fillCanvas.drawImageRect(cutImage, box, onto, own(Skia.Paint()));
|
||||
}
|
||||
|
||||
// The other half of the fill, and the better one: the pixels under the
|
||||
// brush invented from the pixels around them (engine/inpaint.ts) rather
|
||||
// than averaged from the rim — the rim's own light, diffused inward, with
|
||||
// the rim's own grain over it. It is synchronous. It used to be MI-GAN
|
||||
// over an ONNX runtime here, awaited; that was the lag and the worse
|
||||
// repair both (heal.ts and inpaint.ts carry the numbers), so the model is
|
||||
// gone and this is the rung it used to fall back on.
|
||||
// The other half of the fill, and the better one where it can be had: the
|
||||
// pixels under the brush invented by MI-GAN from the pixels around them
|
||||
// (engine/inpaint.ts) rather than averaged from the rim.
|
||||
//
|
||||
// One drag is one repair, and a drag that runs the length of the frame is
|
||||
// solved in crops of its own extent rather than in one downscaled box that
|
||||
@@ -1654,12 +1651,13 @@ function clusterHealSpots(
|
||||
const srcH = srcBottom - srcTop;
|
||||
|
||||
if (srcW >= 8 && srcH >= 8) {
|
||||
// The crop's grid comes from its share of the FRAME and not from the
|
||||
// pixels the frame is rendered at, so the same repair is the same
|
||||
// repair at every zoom and at the export's own size — see
|
||||
// The model's grid comes from the crop's share of the FRAME and not
|
||||
// from the pixels the frame is rendered at, so the same repair is the
|
||||
// same repair at every zoom and at the export's own size — see
|
||||
// INPAINT_REF_LONG. It is also finer than it used to be wherever the
|
||||
// stage is small: a crop a tenth of a 768px preview is 77 pixels, and
|
||||
// 77 pixels of context invent a coarser repair than a bigger one.
|
||||
// 77 pixels of context is what the model used to be asked to invent
|
||||
// from.
|
||||
const { w: mw, h: mh } = inpaintCropSize(
|
||||
(srcW / width) * INPAINT_REF_LONG,
|
||||
(srcH / height) * INPAINT_REF_LONG
|
||||
@@ -1711,18 +1709,20 @@ function clusterHealSpots(
|
||||
}
|
||||
}
|
||||
}
|
||||
const patch = fastLocalInpaint(cropPixels, mw, mh, hole);
|
||||
const patch = await inpaintCrop(cropPixels, mw, mh, hole).catch(() => null);
|
||||
if (patch) {
|
||||
const patchSurface = own(createSurface(mw, mh));
|
||||
if (patchSurface && patchSurface.getCanvas().writePixels(patch, mw, mh, 0, 0)) {
|
||||
flush(patchSurface);
|
||||
const patchImage = own(patchSurface.makeImageSnapshot());
|
||||
if (patchImage) {
|
||||
// The invented pixels under the spot, and nowhere else at
|
||||
// all. They are laid through the mask's own stops: they are
|
||||
// the repair wherever they exist, so they own the whole spot
|
||||
// and give way only in the sliver the mask's edge fades over,
|
||||
// exactly as the blur under them does.
|
||||
// The model's pixels under the spot, and nowhere else at
|
||||
// all. What shape they are laid through is the join between
|
||||
// the model's invention and the photo's real texture, and it
|
||||
// is not the mask's shape: the model is the repair wherever
|
||||
// it has one, so it owns the whole spot and gives way only in
|
||||
// the sliver the mask itself fades over (HEAL_PATCH_STOPS,
|
||||
// and the numbers that put it there are in heal.ts).
|
||||
const patchSurface = own(createSurface(srcW, srcH));
|
||||
if (patchSurface) {
|
||||
const patchCanvas = patchSurface.getCanvas();
|
||||
@@ -1745,7 +1745,7 @@ function clusterHealSpots(
|
||||
[cx, cy],
|
||||
rad,
|
||||
new Float32Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0]),
|
||||
HEAL_MASK_STOPS,
|
||||
HEAL_PATCH_STOPS,
|
||||
Skia.TileMode.Clamp
|
||||
)
|
||||
);
|
||||
@@ -1781,7 +1781,6 @@ function clusterHealSpots(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flush(fillSurface);
|
||||
const fillImage = own(fillSurface.makeImageSnapshot());
|
||||
|
||||
|
||||
@@ -1,50 +1,73 @@
|
||||
// FX > HEAL, the half that invents.
|
||||
//
|
||||
// The dust brush used to be filled with a blur of the photo's own rim — the
|
||||
// light next to the hole, carried in, which is right for a speck of dust on a
|
||||
// wall and thin over anything with structure: a hole across a shirt comes back
|
||||
// as the average of a shirt. What fills it now is a diffusion of that same rim,
|
||||
// a harmonic field with the rim's own grain laid back over it (fastLocalInpaint
|
||||
// below): still no pixel copied from anywhere, and the light of the photo rather
|
||||
// than of the thing being repaired.
|
||||
//
|
||||
// It was MI-GAN over onnxruntime-web for a while too — 27MB of weights fetched
|
||||
// and a session built on the visitor's machine, asked only for the pixels under
|
||||
// the brush. It is gone: on the renderer's own probe (heal-repro2.mjs, five
|
||||
// cases, the same mask and the same frames for both authors) the model was the
|
||||
// slower half AND the worse one on every case it was given — crown-fit 9.28
|
||||
// against 3.29 and 2273ms against 416ms, crown-wide 12.93/4.28 at 2279/359ms,
|
||||
// sky-speck 0.72/0.34 at 4941/559ms, ridge-run 64.29/57.02 at 13013/1170ms —
|
||||
// and on a flat rim it hands back the pixels it was given (migan-probe.mjs: 0.16
|
||||
// of mean change over the plate it was asked to invent) after four seconds. A
|
||||
// drag is solved on every move of the brush, so those seconds were the lag.
|
||||
//
|
||||
// ponytail: a diffusion, not a synthesis — over a hole wider than the structure
|
||||
// around it there is nothing to continue and the answer is a plausible average.
|
||||
// The rung for a synthesis is a model, and the numbers above are what one has to
|
||||
// beat before it comes back.
|
||||
//
|
||||
// What the renderer hands over is a crop of the frame around the brush and the
|
||||
// brush's own circles as a mask; what comes back is the same crop with the hole
|
||||
// invented. Nothing outside the mask is read by the renderer (see step 6e), so a
|
||||
// wrong corner of the crop costs nothing.
|
||||
import type * as Ort from 'onnxruntime-web';
|
||||
|
||||
// The longest side a crop is ever answered at. A repair is a few brush widths
|
||||
// across — the crop has all the context it needs long before this — and the
|
||||
// renderer's own crop is cut to this grid, so the work is bounded by it rather
|
||||
// than by the frame.
|
||||
// FX > HEAL, the half that invents. The dust brush used to be filled with a
|
||||
// blur of the photo's own rim — the light next to the hole, carried in, which is
|
||||
// right for a speck of dust on a wall and thin over anything with structure: a
|
||||
// hole across a shirt comes back as the average of a shirt.
|
||||
//
|
||||
// The fill is now a learned one. MI-GAN (andraniksargsyan/migan, the
|
||||
// `migan_pipeline_v2.onnx` weights inpaint-web ships) is 27MB and runs through
|
||||
// onnxruntime-web on the visitor's own machine, like the upscaler next door and
|
||||
// for the same reason: the photo never leaves the device. It is only asked for
|
||||
// what a GAN is actually good at — the pixels under the brush, and not one
|
||||
// pixel more: everything outside the mask is the frame as rendered, put back by
|
||||
// the composite in the renderer (see step 6e). A model that is wrong about a
|
||||
// corner of the crop costs nothing, because that corner is never read.
|
||||
//
|
||||
// The runtime and the model are static files beside the app; nothing here
|
||||
// changes what the server can do.
|
||||
//
|
||||
// The mask polarity is MI-GAN's own and is not the intuitive one: 255 is KEEP
|
||||
// and 0 is the HOLE to invent — the model repaints exactly where the mask is
|
||||
// zero. Measured on the stroke probe (tmp-migan-probe.mjs, since deleted):
|
||||
// mean |before-after| over the hole 43.12, over everything the mask kept 0.01.
|
||||
// The other way round the frame comes back repainted and the hole untouched,
|
||||
// which is the collage this file exists to avoid.
|
||||
|
||||
const MODEL_URL = '/models/migan_pipeline_v2.onnx';
|
||||
// The wasm dir, not the file: the runtime picks its own name inside it.
|
||||
const WASM_DIR = '/wasm/ort/';
|
||||
// What a HEAL toggle warms: the runtime's own files and the model. Same list as
|
||||
// the upscaler's, whose runtime this is — the bytes are shared.
|
||||
const RUNTIME_FILES = ['ort-wasm-simd-threaded.jsep.mjs', 'ort-wasm-simd-threaded.jsep.wasm'];
|
||||
// The longest side the model is ever handed, before the renderer's crop is
|
||||
// scaled down to it. The graph is fully convolutional and would take a whole
|
||||
// frame; it would also take ten seconds over one, per keystroke of the brush.
|
||||
// 512 is the probe's own size and about a second on four threads of wasm, and a
|
||||
// repair is a few brush widths across — the crop has all the context it needs
|
||||
// long before this. `inpaintCrop` scales what it is given to fit; the renderer
|
||||
// draws the answer back to the crop's own rectangle.
|
||||
export const INPAINT_MAX_SIDE = 512;
|
||||
// The frame's long side, as a crop's grid is measured. The stage re-renders the
|
||||
// frame on the zoom ladder (PREVIEW_QUANTUM in App.tsx, up to PREVIEW_HARD_MAX),
|
||||
// so the same spots of the same photo are a different number of pixels at every
|
||||
// zoom; a crop sized from those pixels would be a different grid each time, and
|
||||
// every grid invents its own pixels — which is why a repair used to change every
|
||||
// time the user zoomed back out of it, and drifted a little further with each
|
||||
// pass. Sized against a fixed long side instead, the crop's box is a fraction of
|
||||
// the frame and the grid it asks for is the same repair at every zoom and at the
|
||||
// export's own size. 2048 is past the stage's own PREVIEW_MAX, so a crop small
|
||||
// enough to matter is never scaled up to reach it.
|
||||
// The frame's long side, as a crop's grid is measured for the model. The stage
|
||||
// re-renders the frame on the zoom ladder (PREVIEW_QUANTUM in App.tsx, up to
|
||||
// PREVIEW_HARD_MAX), so the same spots of the same photo are a different number
|
||||
// of pixels at every zoom; a crop sized from those pixels handed the model a
|
||||
// different grid each time, and every grid invents its own pixels — which is why
|
||||
// a repair changed every time the user zoomed back out of it, and drifted a
|
||||
// little further with each pass. Sized against a fixed long side instead, the
|
||||
// crop's box is a fraction of the frame and the grid it asks for is the same
|
||||
// repair at every zoom and at the export's own size. 2048 is past the stage's
|
||||
// own PREVIEW_MAX, so a crop small enough to matter is never scaled up to reach
|
||||
// it.
|
||||
//
|
||||
// ponytail: the grid is the same; the pixels handed to it are still read from
|
||||
// whatever the stage rendered at, so the model's answer is the same repair to
|
||||
// within the resampling of its input and not to the byte. Caching the patch
|
||||
// across renders under a key that names the frame and the spots rather than the
|
||||
// render's pixels is the rung that makes it identical, and it is the one to take
|
||||
// if a zoom still shows a repair move.
|
||||
export const INPAINT_REF_LONG = 2048;
|
||||
// The one throwaway run when the session opens: 64 pixels square, but a whole
|
||||
// number of the graph's own stride, so the kernels it builds are the kernels the
|
||||
// repairs use.
|
||||
const WARM_SIDE = 64;
|
||||
|
||||
interface Loaded {
|
||||
ort: typeof Ort;
|
||||
session: Ort.InferenceSession;
|
||||
}
|
||||
|
||||
let loaded: Promise<Loaded> | null = null;
|
||||
|
||||
function isMobile(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
@@ -55,6 +78,111 @@ function isMobile(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isIOS(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
const ua = navigator.userAgent || '';
|
||||
return (
|
||||
/iPad|iPhone|iPod/i.test(ua) ||
|
||||
(navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1)
|
||||
);
|
||||
}
|
||||
|
||||
// One warm-up run through the graph, answering whether the session it was given
|
||||
// can actually execute the model. WebGPU is worth having — a repair lands in
|
||||
// well under a second instead of a second and a half — but a provider the graph
|
||||
// does not fit is a session that opens happily and throws on the first pixel, so
|
||||
// the trial is a real run and not a `create` that returned.
|
||||
async function warmsUp(ort: typeof Ort, session: Ort.InferenceSession): Promise<boolean> {
|
||||
try {
|
||||
const in0 = new ort.Tensor('uint8', new Uint8Array(3 * WARM_SIDE * WARM_SIDE), [
|
||||
1, 3, WARM_SIDE, WARM_SIDE,
|
||||
]);
|
||||
const in1 = new ort.Tensor('uint8', new Uint8Array(WARM_SIDE * WARM_SIDE).fill(255), [
|
||||
1, 1, WARM_SIDE, WARM_SIDE,
|
||||
]);
|
||||
const out = await session.run({
|
||||
[session.inputNames[0]]: in0,
|
||||
[session.inputNames[1]]: in1,
|
||||
});
|
||||
try {
|
||||
in0.dispose?.();
|
||||
in1.dispose?.();
|
||||
out[session.outputNames[0]]?.dispose?.();
|
||||
} catch {}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the browser has a device at all.
|
||||
// Whether the browser has a device at all.
|
||||
// Mobile devices (Android Chrome and iOS WebKit) have unified memory architectures where
|
||||
// WebGPU buffer allocation destroys WebGL contexts (CanvasKit GrDirectContext loss -> black screen).
|
||||
// Therefore WebGPU is strictly restricted to desktop.
|
||||
async function hasDevice(): Promise<boolean> {
|
||||
if (isMobile()) return false;
|
||||
try {
|
||||
return Boolean(await navigator.gpu?.requestAdapter());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// WebGPU first, the processor as the answer to every way that can fail: no
|
||||
// adapter, no `shader-f16` needed here but an op the provider never
|
||||
// implemented, a driver that opens a session and dies on the first dispatch.
|
||||
async function openSession(ort: typeof Ort): Promise<Ort.InferenceSession> {
|
||||
if (await hasDevice()) {
|
||||
try {
|
||||
const gpu = await ort.InferenceSession.create(MODEL_URL, { executionProviders: ['webgpu'] });
|
||||
if (await warmsUp(ort, gpu)) return gpu;
|
||||
await gpu.release();
|
||||
} catch {
|
||||
// Whatever went wrong, the wasm provider is the fallback and not a failure.
|
||||
}
|
||||
}
|
||||
return ort.InferenceSession.create(MODEL_URL, { executionProviders: ['wasm'] });
|
||||
}
|
||||
|
||||
// Loads the runtime and the model once per page, and runs one warm-up pass so
|
||||
// the first repair is not the one that pays for the kernels.
|
||||
function load(): Promise<Loaded> {
|
||||
if (!loaded) {
|
||||
loaded = (async () => {
|
||||
const ort = await import('onnxruntime-web');
|
||||
ort.env.wasm.wasmPaths = WASM_DIR;
|
||||
const mobile = isMobile();
|
||||
// On mobile devices, 1 worker thread keeps memory strictly under the browser tab limit.
|
||||
// On desktop, up to 4 threads if cross-origin isolated.
|
||||
ort.env.wasm.numThreads = mobile
|
||||
? 1
|
||||
: self.crossOriginIsolated
|
||||
? Math.min(4, navigator.hardwareConcurrency || 1)
|
||||
: 1;
|
||||
// On mobile and desktop browsers, proxy=false runs direct in-thread WASM execution,
|
||||
// avoiding Web Worker proxy script loading failures and cross-origin worker restrictions.
|
||||
ort.env.wasm.proxy = false;
|
||||
ort.env.webgpu.powerPreference = 'high-performance';
|
||||
const session = await openSession(ort);
|
||||
return { ort, session };
|
||||
})().catch((err) => {
|
||||
// A failed load must not poison the next attempt.
|
||||
loaded = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
// Start fetching the model before the brush that needs it, so the visitor waits
|
||||
// for the repair and not for the download. Called when HEAL is switched on.
|
||||
// Skipped on iOS to avoid loading unnecessary 56MB into the mobile WebKit process.
|
||||
export function preloadInpaint(): void {
|
||||
if (isIOS() || isMobile()) return;
|
||||
void load().catch(() => {});
|
||||
}
|
||||
|
||||
const byte = (v: number) => (v < 0 ? 0 : v > 255 ? 255 : Math.round(v));
|
||||
|
||||
// How many pixels the hole is.
|
||||
@@ -325,6 +453,226 @@ export function fastLocalInpaint(
|
||||
return out;
|
||||
}
|
||||
|
||||
// What the model is handed: the crop at the model's own grid, and the mask in
|
||||
// the model's own polarity — 255 where the frame is known, 0 over the hole.
|
||||
//
|
||||
// The pixels it reads UNDER the hole are the light of the hole's own rim, spread
|
||||
// inward, and not the flat plate the crop used to be filled with: that was the
|
||||
// mean of every pixel of the crop, which is a colour that appears nowhere near
|
||||
// the repair as soon as the crop is bigger than the thing inside it (a blemish
|
||||
// on a dark ground, a wire across a bright sky). The network then reads a hard
|
||||
// step at the mask's own edge, answers in the plate's colour, and the repair
|
||||
// lands as a patch of the wrong light on the frame's own. The rim's light meets
|
||||
// the photo along the mask's edge by construction, so there is no step for the
|
||||
// network to answer, and the context it reads is the photo's own.
|
||||
//
|
||||
// The mask is downsized by "any hole pixel in this cell" rather than by whichever
|
||||
// pixel a cell's corner samples: the renderer hands a crop cut to this grid, and
|
||||
// snapping that to whole strides of the graph can drop the last rows of it — with
|
||||
// it, a brush thinner than the loss, and a frame handed to the model with nothing
|
||||
// to invent comes back as the photo it already had.
|
||||
export function cropPlate(
|
||||
rgba: Uint8Array | Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
hole: Uint8Array,
|
||||
w: number,
|
||||
h: number
|
||||
): { src: Uint8Array; hole: Uint8Array } {
|
||||
const sx = width / w;
|
||||
const sy = height / h;
|
||||
const small = new Uint8Array(4 * w * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const syy = Math.min(height - 1, Math.floor(y * sy));
|
||||
for (let x = 0; x < w; x++) {
|
||||
const sxx = Math.min(width - 1, Math.floor(x * sx));
|
||||
const s = (syy * width + sxx) * 4;
|
||||
const d = (y * w + x) * 4;
|
||||
small[d] = rgba[s];
|
||||
small[d + 1] = rgba[s + 1];
|
||||
small[d + 2] = rgba[s + 2];
|
||||
small[d + 3] = 255;
|
||||
}
|
||||
}
|
||||
const smallHole = new Uint8Array(w * h);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
if (!hole[i]) continue;
|
||||
const x = Math.min(w - 1, Math.floor((i % width) / sx));
|
||||
const y = Math.min(h - 1, Math.floor(((i / width) | 0) / sy));
|
||||
smallHole[y * w + x] = 1;
|
||||
}
|
||||
|
||||
const plane = w * h;
|
||||
const src = new Uint8Array(3 * plane);
|
||||
const mask = new Uint8Array(plane).fill(255);
|
||||
const field = rimFill(small, w, h, smallHole, 0);
|
||||
for (let d = 0; d < plane; d++) {
|
||||
const s = d * 4;
|
||||
if (smallHole[d]) {
|
||||
src[d] = byte(field.r[d]);
|
||||
src[plane + d] = byte(field.g[d]);
|
||||
src[2 * plane + d] = byte(field.b[d]);
|
||||
mask[d] = 0;
|
||||
} else {
|
||||
src[d] = small[s];
|
||||
src[plane + d] = small[s + 1];
|
||||
src[2 * plane + d] = small[s + 2];
|
||||
}
|
||||
}
|
||||
return { src, hole: mask };
|
||||
}
|
||||
|
||||
const patchCache = new Map<string, Uint8Array>();
|
||||
let inFlightInpaint: Promise<Uint8Array | null> | null = null;
|
||||
|
||||
export function clearInpaintCache(): void {
|
||||
patchCache.clear();
|
||||
}
|
||||
|
||||
// The page is going away, or coming back from the back/forward cache: hand the
|
||||
// session back rather than leaving the browser to reap its buffers and its
|
||||
// thread pool with the page. `loaded` goes with it, so a page that does come
|
||||
// back loads the runtime again instead of calling into a released session — the
|
||||
// files are cached, so that is a second.
|
||||
export async function releaseInpaint(): Promise<void> {
|
||||
patchCache.clear();
|
||||
const pending = loaded;
|
||||
loaded = null;
|
||||
if (!pending) return;
|
||||
try {
|
||||
await (await pending).session.release();
|
||||
} catch {
|
||||
// A load that failed, or a runtime too old to have `release`.
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof addEventListener === 'function') {
|
||||
addEventListener('pagehide', () => {
|
||||
void releaseInpaint();
|
||||
});
|
||||
}
|
||||
|
||||
// The pixels under the brush, invented from the pixels around them.
|
||||
export async function inpaintCrop(
|
||||
rgba: Uint8Array | Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
hole: Uint8Array
|
||||
): Promise<Uint8Array | null> {
|
||||
|
||||
const maxSide = isMobile() ? 256 : INPAINT_MAX_SIDE;
|
||||
let w = Math.max(8, Math.round(width));
|
||||
let h = Math.max(8, Math.round(height));
|
||||
const scale = Math.min(1, maxSide / Math.max(w, h));
|
||||
// Whole multiples of the graph's own stride, so the last block of the encoder
|
||||
// is a block and not a remainder: rounded down, since the renderer's crop is
|
||||
// wider than the mask by its own margin and losing a sliver of that margin
|
||||
// costs the mask nothing.
|
||||
w = Math.max(8, Math.floor((w * scale) / 8) * 8);
|
||||
h = Math.max(8, Math.floor((h * scale) / 8) * 8);
|
||||
|
||||
// Quick signature to avoid redundant re-inferences on subsequent renders
|
||||
let holeCount = 0;
|
||||
let holeHash = 0;
|
||||
const step = Math.max(1, Math.floor(hole.length / 64));
|
||||
for (let i = 0; i < hole.length; i += step) {
|
||||
if (hole[i]) {
|
||||
holeCount++;
|
||||
holeHash = (holeHash * 31 + i) | 0;
|
||||
}
|
||||
}
|
||||
let pixHash = 0;
|
||||
const pixStep = Math.max(4, Math.floor(rgba.length / 64) * 4);
|
||||
for (let i = 0; i < rgba.length; i += pixStep) {
|
||||
pixHash = (pixHash * 33 + rgba[i] + rgba[i + 1]) | 0;
|
||||
}
|
||||
const key = `${w}x${h}_${holeCount}_${holeHash}_${pixHash}`;
|
||||
|
||||
if (patchCache.has(key)) {
|
||||
return patchCache.get(key)!.slice();
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Single-flight lock: serialize inference executions to prevent WebAssembly memory spikes
|
||||
if (inFlightInpaint) {
|
||||
try {
|
||||
await inFlightInpaint;
|
||||
} catch {}
|
||||
if (patchCache.has(key)) {
|
||||
return patchCache.get(key)!.slice();
|
||||
}
|
||||
}
|
||||
|
||||
const runPromise = (async (): Promise<Uint8Array | null> => {
|
||||
// NCHW, 0..255 RGB — what the model was trained to read — and the mask in its
|
||||
// own polarity. Both are built by cropPlate, which is where the light the
|
||||
// model reads under the hole comes from.
|
||||
const { src, hole: mask } = cropPlate(rgba, width, height, hole, w, h);
|
||||
|
||||
try {
|
||||
// Allow up to 15000ms for neural inpainting session to initialize and synthesize scene context
|
||||
const sessionPromise = load();
|
||||
const loadWithTimeout = Promise.race([
|
||||
sessionPromise,
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Load timeout')), 15000)),
|
||||
]);
|
||||
const { ort, session } = await loadWithTimeout;
|
||||
const in0 = new ort.Tensor('uint8', src, [1, 3, h, w]);
|
||||
const in1 = new ort.Tensor('uint8', mask, [1, 1, h, w]);
|
||||
|
||||
const runWithTimeout = Promise.race([
|
||||
session.run({
|
||||
[session.inputNames[0]]: in0,
|
||||
[session.inputNames[1]]: in1,
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Inference timeout')), 15000)
|
||||
),
|
||||
]);
|
||||
|
||||
const out = await runWithTimeout;
|
||||
const tensor = out[session.outputNames[0]];
|
||||
const oplane = w * h;
|
||||
const data = tensor.data as Uint8Array;
|
||||
const patch = new Uint8Array(4 * w * h);
|
||||
for (let i = 0; i < oplane; i++) {
|
||||
patch[i * 4] = data[i];
|
||||
patch[i * 4 + 1] = data[oplane + i];
|
||||
patch[i * 4 + 2] = data[2 * oplane + i];
|
||||
patch[i * 4 + 3] = 255;
|
||||
}
|
||||
try {
|
||||
in0.dispose?.();
|
||||
in1.dispose?.();
|
||||
tensor.dispose?.();
|
||||
for (const k in out) out[k]?.dispose?.();
|
||||
} catch {}
|
||||
|
||||
if (patchCache.size > 50) {
|
||||
const first = patchCache.keys().next().value;
|
||||
if (first) patchCache.delete(first);
|
||||
}
|
||||
patchCache.set(key, patch.slice());
|
||||
return patch;
|
||||
} catch {
|
||||
// Fallback: fast local inpaint with multi-step Poisson harmonic propagation.
|
||||
// Do NOT cache the fallback in patchCache so that once neural inpainting finishes
|
||||
// initializing, subsequent renders will execute neural context interpolation.
|
||||
return fastLocalInpaint(rgba, width, height, hole);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightInpaint = runPromise;
|
||||
try {
|
||||
return await runPromise;
|
||||
} finally {
|
||||
if (inFlightInpaint === runPromise) {
|
||||
inFlightInpaint = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The size `inpaintCrop` will actually run at, for a caller that has to read the
|
||||
// crop back at the model's resolution rather than at the frame's. The renderer
|
||||
// uses it to cut the crop it hands over: a 3000px box read back at 3000px and
|
||||
|
||||
Reference in New Issue
Block a user