Files
RecipesCam/docker/frontend/shared/utils/heal.ts
T
3dtours 88d6d0e648 web: read the ring's light by direction, and off the dust's soft edge
The last commit pasted the borrowed patch at the light of the place it lands in,
and read that light as one number per spot: the mean of the ring around the dust
minus the mean of the same ring around the patch. That number is a light the
place has when the ring is all one thing. It is not a light the place has when
the ring is not. A twig, a hairline, the edge of a table under the brush, and a
minority of the taps stand on the thing rather than on the ground: the mean then
follows the minority — it is a colour the place never had — and the patch is
pasted in it. The donor was of exactly the right light, the search had gated it
at LIGHT_GATE, and the repair still lands as a dark blotch. A mean is the wrong
estimator for a ring that is not one thing; the search already knew that, and
reads its own ring as a median for the same reason.

So the light is read once PER DIRECTION. Each of the sixteen taps is a pair of
readings — the place's ring and the patch's ring at the same sixteen places —
and a pixel takes the correction of the two readings it lies between,
interpolated by its own angle around the spot, in the same single draw. The rim
then meets the place all the way round instead of on average: a tap that landed
on the twig bends the part of the rim near the twig, and the far side of the
circle is left where it was. Sixteen taps rather than eight because a tap's
influence reaches only as far as the next tap, so the finer the ring, the less
of the rim one hard pixel of the photo can drag with it.

The second half of the change came out of the app, not out of the lab. With the
per-direction reading and no other change, heal-probe.cjs went from 49 PASS to
41 PASS / 8 FAIL: the repair's own centre came out 15-18 levels dark on a flat
field, with the frame around it clean. The reason is the estimator again, from
the other end — one direction is one pair of pixels and carries no averaging, so
whatever the ring reads at that direction, the patch gets in full. And the ring
at RING_R alone is not clear of the dust: a speck spreads about a pixel past
where it is drawn in the pixels the shader samples, so the nearest taps sit
inside the dust's own soft edge and read the dust's light. The mean had been
hiding it: one contaminated tap in eight is a level off; the same tap read whole
is the blotch. The ring is now a pixel further out again (RING_PAD), in the same
pixels the sampler works in — a fraction of the radius would be nothing at all at
the sensor-dust end of the brush, which is where this tool is aimed — and the
probe is back to 49 PASS / 0 FAIL.

Measured on a sweep of the two ways of reading it (heal-ring-sweep.cjs, CanvasKit,
three scenes, the step the eye reads at the rim plus the level of the patch's own
middle against the ground it landed in, levels out of 255):

  scene                     shipped mean 8@1.15        this: 16 taps, per direction
  uniform light difference  step 0, centre 0           step 0, centre 0
  twig across the ring      step 53 (mean 16.4),       step 56 (mean 2.4),
                            centre 34 dark             centre 0
  brush fits the speck      centre 5 dark              centre 0

The worst step on the twig scene is unchanged — that is the twig's own edge
crossing the rim, which no level can meet, and the floor the copy set at 85. What
moved is the average (16.4 levels to 2.4) and the level of the patch's middle,
which is the blotch: 34 levels of a place that never had them, down to none.

No new dependency. cv.seamlessClone is the same thing this shader already does —
the membrane half of a Poisson edit — and OpenCV.js would be 5-10 MB off a CDN,
solved on the CPU per spot, outside the one draw the preview, the recipe and the
export all read from: the correction is recomputed from the snapshot on every
render, which is why the preview and the exported file agree by construction and
why a saved photo opens onto the same repair. It also cannot run in the worker
the brush paints in or against the fractions the recipe stores.

Verified:
  heal-blotch-lab.cjs (scratchpad, CanvasKit, no browser) — new, 12 PASS / 0
    FAIL, and 8 PASS / 4 FAIL against the bundle built from 57ade27, which is what
    the lab is for. Two scenes, each run twice through the real pipeline: the
    mean (kept inline in the lab as the "before") against healSkSL from the
    bundled heal.ts. Twig across the ring: the mean puts the patch's middle down
    at 116 against a ground of 150 (34 levels dark), the module at 150. Brush
    fitting the speck exactly: 145 against 150, the module 150. In both, the dust
    is gone rather than dimmed, and the frame away from the circle is the photo.
  heal-edge-lab.cjs — 9 PASS / 0 FAIL, its ring metric narrowed to the rim the
    ring has already handed back to the light (one tap spacing either side of the
    band excluded: the rim nearest the band is bent toward the band on purpose,
    and that bend is the fix, not an error). Light side of the rim 0 levels off
    (the one mean level: 32), and the band the ring caught is met at 34 against
    the copy's 90 — under the old ceiling, not over it.
  heal-seam-lab.cjs 10, heal-skia-lab.cjs 28, heal-search-lab.cjs 15,
    heal-probe.cjs 49, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8,
    mosaic-skia-lab.cjs 27, mosaic-probe.cjs 51 — all 0 FAIL, against the rebuilt
    app at http://localhost:8090 (docker compose up -d --build frontend).
  Regressions against the rebuilt app, rc=0, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
    tsc --noEmit clean.

ponytail: the correction lives on the rim — every pixel takes the two readings it
lies between and interpolates — so it is the boundary of a Poisson edit and not
its interior: a repair laid over something the patch cannot reproduce still
carries the patch's own texture inside, bent to fit the rim, and the other half
of the solve (a correction that relaxes inside the circle, a Jacobi ping-pong per
spot) lands when a real photo shows an interior the eye can find from the rim
alone. A direction's level is one tap pair on each side, so the grain the mean used
to average away now rides the rim as a wedge of a level or two across the circle —
a tangential average of three taps per direction is the rung for that, when a
photo shows the wedge. RING_PAD is one pixel because that is the width of the
dust's own soft edge in the sampler's pixels; a speck whose blur is wider than
that still reaches the ring. The source is still found by the ring search — eight
directions at three distances, each mirrored — and not by PatchMatch, and the
recipe still stores fractions with no correction in it, so nothing about this
change is versioned in a saved photo.
2026-09-24 06:29:47 +07:00

325 lines
16 KiB
TypeScript

import type { HealSpot } from '../types';
// FX tab > HEAL — the dust brush, and the patch search behind it.
//
// A spot is a circle on the rendered photo plus the patch it borrows: the
// renderer copies the pixels at (sx, sy) onto (x, y), feathers the edge, and
// shifts what it pasted onto the light of the place it landed in, so a repair
// is a draw of the same picture rather than a blur over the dust. All five
// numbers are fractions of the render — x/y/sx/sy of its width and height, r of
// its width — which is what makes one set of spots survive the preview and the
// export rendering the same photo at two sizes, and keeps the circle round
// whatever the photo's shape.
//
// There is no ceiling on the list. The shader is built to carry exactly the
// spots the recipe holds (healSkSL), so a new repair can never push an old one
// out: the dust you healed first is the dust that stays healed.
//
// The source is SEARCHED for rather than asked for. Lightroom picks the patch
// and lets you drag the second circle afterwards; the search below is the same
// idea without the second circle, and it is a pure function of a sampler so a
// synthetic picture can hold it to account.
//
// Feather, as a fraction of the radius: inside it the patch is copied, outward
// it fades to nothing, so the circle leaves no rim of its own. It is the outer
// 15% and no more, because that band is the only place the dust being repaired
// is mixed back into the patch — a wide fade keeps the speck's own edge alive
// as a faint ring inside the circle, which is a blur of the dust rather than a
// repair. sub-pixel at the default brush, still a soft edge at a big one.
export const HEAL_FEATHER = 0.85;
// The brush's radius, as a fraction of the photo's width — where this tool
// starts, at the sensor-dust end, where a speck is a few thousandths of the
// frame. The bounds and the wheel that moves it between them belong to the
// brush both FX tools share (brush.ts).
export const HEAL_DEFAULT_R = 0.012;
// How far the search looks, in radii, and how many directions it looks in.
const SEARCH_DISTANCES = [2.6, 4.2, 6.5];
const SEARCH_DIRS = 8;
// The taps that stand for "the light around a spot": a ring just OUTSIDE the
// brush, where the dust being repaired is not — the scale the eye reads a
// spot's surroundings at. Reading the inside instead compares a candidate with
// the very dark the repair is trying to erase, and the patch that matches a
// speck best is then the one carrying a speck of its own — which is how a
// repair ends up moving dust a few pixels instead of removing it.
//
// Twelve taps read as their MEDIAN, not their mean. The ring can only be a
// little way out — far enough out and it would be reading a light the spot does
// not sit in — so a few of its taps land on the speck's own softened edge, or
// on a neighbour's: readings in the minority, which the median drops and the
// mean would drag the whole light down by.
const RING_R = 1.15;
const RING_TAPS: [number, number][] = Array.from({ length: 12 }, (_, i) => {
const a = (i / 12) * Math.PI * 2;
return [Math.cos(a), Math.sin(a)] as [number, number];
});
// The taps that decide whether a candidate patch is clean: the inside of the
// patch, held against the patch's own mean. Dust is an outlier in its own
// neighbourhood; skin, however grainy, is not. The last ring is the patch's own
// edge — the circle is copied at full strength out to HEAL_FEATHER of its
// radius, so that is where a neighbour's dust leaking into the patch shows up,
// and the middle of the patch would never see it.
const INSIDE_TAPS: [number, number][] = [
[0, 0],
[-0.5, 0],
[0.5, 0],
[0, -0.5],
[0, 0.5],
[-0.35, -0.35],
[0.35, -0.35],
[-0.35, 0.35],
[0.35, 0.35],
...Array.from({ length: 8 }, (_, i) => {
const a = (i / 8) * Math.PI * 2;
return [Math.cos(a) * HEAL_FEATHER, Math.sin(a) * HEAL_FEATHER] as [number, number];
}),
];
// How far a candidate's light may sit from the spot's own before it is refused
// outright, in the 0-255 levels the sampler answers in. Sending a patch from a
// different light is exactly what makes a repair show up as a mark of its own —
// a light square where dark ground was, a dark one where light was — so past
// this the search gives up and the caller leaves the speck alone. The user sees
// an untouched speck, which is honest, and tries another size or another spot.
const LIGHT_GATE = 20;
// Light outweighs cleanliness in the score, so the winner is the same light
// first and the cleaner patch second; within the gate the cleanliness term only
// breaks ties between patches the eye would call the same.
const LIGHT_WEIGHT = 3;
const num = (v: unknown, fallback: number) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
// The stored spots, made readable: numbers, inside the frame. Everything below
// reads a recipe through this, so a hand-written or older file cannot produce a
// spot the brush and the renderer disagree about.
export function readHeal(heal: HealSpot[] | undefined): HealSpot[] {
if (!Array.isArray(heal)) return [];
return heal
.map((s) => ({
x: clamp01(num(s?.x, 0)),
y: clamp01(num(s?.y, 0)),
r: Math.max(0, num(s?.r, 0)),
sx: clamp01(num(s?.sx, s?.x ?? 0)),
sy: clamp01(num(s?.sy, s?.y ?? 0)),
}))
.filter((s) => s.r > 0);
}
// The uniform block the shader for `n` spots reads: the circles, the patches,
// then the frame the fractions are of. Declaration order, arrays expanded —
// that is how the runtime effect wants its uniforms, and one buffer is one
// upload per render. Its length is a function of the list, not a fixed
// capacity, because the shader carries exactly the spots the recipe holds.
export function healUniforms(spots: HealSpot[], width: number, height: number): Float32Array {
const list = readHeal(spots);
const n = list.length;
const u = new Float32Array((n * 2 + 1) * 4);
for (let i = 0; i < n; i++) {
const s = list[i];
u.set([s.x, s.y, s.r, 0], i * 4);
u.set([s.sx, s.sy, 0, 0], (n + i) * 4);
}
u.set([width, height, 0, 0], n * 2 * 4);
return u;
}
// The ring the pasted circle is matched at, and how its taps are laid out.
// Measured just OUTSIDE the brush — RING_R of its radius, where the search
// reads a spot's light too — because that is the only ring with nothing of the
// repair in it: inside the circle is the borrowed patch at one radius and the
// speck being covered at another, and a light read off either of those is the
// dust talking rather than the photo. Outside, both sides are photographs — the
// ground the repair has to sit in, and the ground the patch was borrowed from.
// Sixteen taps are spread around it, at the SAME sixteen places on both sides,
// so a tap's pair of readings differ by the light between the two places and
// not by where in the photo they were taken. Sixteen rather than eight because
// a tap that lands on an edge bends its own direction only as far as the next
// ring tap: the finer the ring, the less of the rim one hard pixel of the photo
// can reach around.
const BLEND_TAPS = 16;
// ...and one pixel further out again than RING_R. The ring is read by a pixel
// that lies under the brush, so the reading has to clear the dust's own edge,
// and that edge is soft: a speck r pixels across spreads about a pixel past
// where it is drawn, in the pixels the shader samples. RING_R alone leaves the
// nearest tap or two inside that spread, and a tap that lands on the dust is
// the dust's light — which is exactly the blotch this reading exists to avoid.
// One pixel is the width of the spread, in the same pixels the sampler works
// in, so it is a pixel here rather than a fraction of the radius: a fraction
// would be nothing at all at the sensor-dust end of the brush.
const RING_PAD = 1;
// ...but one light for the whole circle is a light no ring has when the ring is
// not all one thing. A twig against the sky, a hairline, the edge of a table
// under the brush: a few taps of the ring land on the thing and the rest on the
// ground, and the MEAN of them all is a colour that is neither — a level the
// place does not have, pasted over the whole circle. The donor can be of exactly
// the right light and still come out as a blotch, which is the mark the repair
// was supposed to stop leaving. So the light is read once PER DIRECTION, and a
// pixel takes the correction of the two readings it lies between, interpolated
// by its own angle around the spot: the rim then meets the place all the way
// round instead of on average, and the taps that landed on the twig correct the
// part of the rim that is near the twig rather than the whole of it. It is the
// same taps and the same one draw — each pixel reads them at its own angle
// instead of sharing everyone's single number.
// One unrolled block per spot. SkSL indexes a uniform array by constant only
// (see TONE_SKSL's mixer), so the spots are written out rather than looped, and
// the shader is built for the count it is handed rather than for a capacity —
// that is what lets the list be uncapped. A count costs one RuntimeEffect to
// compile, so the renderer caches them by count (exportEngine's healEffectFor).
//
// What is pasted is the patch's own pixels SHIFTED onto the light of the place
// it lands in — the gradient of the borrowed texture, carried at the level of
// the destination: the border is made to match, and everything inside keeps the
// texture it was borrowed for. That is the whole of "seamless" the brush needs:
// at the rim the patch sits within a level or two of the photo around it,
// instead of up to LIGHT_GATE levels off it, so the repair stops reading as a
// soft blotch of its own and the feather has almost nothing left to hide. The
// correction is read per direction around the ring, so the border is matched
// where it is rather than on average, and the pixels of a twig that run through
// the ring bend the part of the rim they are near instead of the whole circle.
//
// ponytail: the correction lives on the rim — every pixel takes the two ring
// readings it lies between and interpolates — so it is the boundary of a Poisson
// edit and not its interior: a repair laid over something the patch cannot
// reproduce still carries the patch's own texture inside, bent to fit the rim.
// The other half of the solve (a correction that relaxes inside the circle, a
// Jacobi ping-pong per spot) lands when a real photo shows an interior the eye
// can find from the rim alone. A direction's level is also one tap pair on each
// side, so the grain the mean used to average away now rides the rim as a wedge
// of a level or two across the circle — a tangential average of three taps per
// direction is the rung for that, when a photo shows the wedge.
const spotBlock = (i: number) => `
{
float4 s = spots[${i}];
if (s.z > 0.0) {
float rad = s.z * size.x;
float d = distance(pos, s.xy * size.xy);
if (d < rad) {
float4 t = srcs[${i}];
float2 pd = s.xy * size.xy;
float2 ps = t.xy * size.xy;
float r = rad * ${HEAL_FEATHER};
float rr = rad * ${RING_R} + ${RING_PAD.toFixed(1)};
// Which way round the spot this pixel sits, one millionth of a pixel off
// the exact centre so the angle is defined there too.
float ang = atan(pos.y - pd.y + 1e-6, pos.x - pd.x + 1e-6);
half3 corr = half3(0.0);
float wsum = 0.0;
for (int k = 0; k < ${BLEND_TAPS}; k++) {
float a = float(k) * 6.283185307 / float(${BLEND_TAPS});
float2 o = float2(cos(a), sin(a)) * rr;
// How far this pixel's angle is from this tap's, the short way round.
float da = ang - a;
da = abs(da - 6.283185307 * floor(da * 0.1591549431 + 0.5));
float w = max(0.0, 1.0 - da * float(${BLEND_TAPS}) * 0.1591549431);
corr += half(w) * (img.eval(pd + o).rgb - img.eval(ps + o).rgb);
wsum += w;
}
corr *= half(1.0 / wsum);
half m = half(1.0 - smoothstep(r, rad, d));
c = mix(c, img.eval(pos + ps - pd) + half4(corr, 0.0), m);
}
}
}
`;
// The pass. It reads the pixels the pipeline has already built (the child is a
// snapshot of the surface) and writes the borrowed patches back over them, so a
// repair is one draw: no blur, no smoothing, and the grain and the frame land
// on top of it afterwards exactly as they land on the rest of the photo.
export function healSkSL(count: number): string {
return `
uniform shader img;
uniform float4 spots[${count}];
uniform float4 srcs[${count}];
uniform float4 size;
half4 main(float2 pos) {
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => spotBlock(i)).join('')}
return c;
}
`;
}
// The patch to borrow for a spot at (x, y) of radius r, from a sampler that
// answers fractions of the same photo. The candidates are a ring of offsets in
// eight directions at three distances — the patch has to be far enough that the
// dust is not in it, near enough that the light is the same — plus each one
// mirrored through the spot, which is the pair Lightroom's own auto-source
// leans on.
//
// Each candidate is asked two questions. "Is this the same light?" holds the
// mean of what would be pasted against the mean of the spot's own ring, the
// pixels just outside the dust — the light the repair has to sit in, read where
// the dust is not, because comparing against the spot's own inside compares a
// candidate with the very dark the repair is trying to erase, and the patch
// that matches a speck best is then the one carrying a speck of its own. "Is
// there dust on it?" holds the candidate's inside against its own inside mean:
// dust is an outlier in its own neighbourhood, grain is not. A candidate whose
// light is off by more than LIGHT_GATE is not scored at all — a patch from the
// wrong light is a mark of its own, and moving it a few pixels for a wrong
// clone is not a repair.
//
// Returns null when the frame is too small to hold any candidate, or when every
// candidate is refused: the caller then leaves the speck where it is rather
// than pasting a patch it cannot stand behind.
export function findHealSource(
sample: (fx: number, fy: number) => { r: number; g: number; b: number },
x: number,
y: number,
r: number
): { sx: number; sy: number } | null {
if (!(r > 0)) return null;
const inside = (cx: number, cy: number) => cx - r >= 0 && cx + r <= 1 && cy - r >= 0 && cy + r <= 1;
const rgb = (v: { r: number; g: number; b: number }) => (v.r + v.g + v.b) / 3;
// The light the repair has to sit in: the median of the spot's ring, where
// the dust is not. Measured once, because it is the same number for every
// candidate.
const ring = RING_TAPS.map(([dx, dy]) => rgb(sample(clamp01(x + dx * RING_R * r), clamp01(y + dy * RING_R * r)))).sort(
(a, b) => a - b
);
const target = (ring[RING_TAPS.length / 2 - 1] + ring[RING_TAPS.length / 2]) / 2;
// The score of one candidate: null when it is refused for its light, else how
// far off that light is times its weight, plus how much the patch varies
// inside itself.
const score = (cx: number, cy: number): number | null => {
let content = 0;
const inside: number[] = [];
for (const [dx, dy] of INSIDE_TAPS) {
const v = rgb(sample(clamp01(cx + dx * r), clamp01(cy + dy * r)));
inside.push(v);
content += v;
}
content /= INSIDE_TAPS.length;
const light = Math.abs(target - content);
if (light > LIGHT_GATE) return null;
let dirty = 0;
for (const v of inside) dirty += Math.abs(v - content);
return light * LIGHT_WEIGHT + dirty / INSIDE_TAPS.length;
};
let best: { sx: number; sy: number; score: number } | null = null;
for (let d = 0; d < SEARCH_DIRS; d++) {
const a = (d / SEARCH_DIRS) * Math.PI * 2;
for (const dist of SEARCH_DISTANCES) {
const cx = x + Math.cos(a) * dist * r;
const cy = y + Math.sin(a) * dist * r;
for (const [px, py] of [
[cx, cy],
[2 * x - cx, 2 * y - cy],
]) {
if (!inside(px, py)) continue;
const s = score(px, py);
if (s === null) continue;
// A tie keeps the earlier candidate: the ring is walked from the right,
// so the patch nearest the spot wins — the one most likely to share its
// light.
if (!best || s < best.score) best = { sx: px, sy: py, score: s };
}
}
}
return best ? { sx: best.sx, sy: best.sy } : null;
}