Files
RecipesCam/docker/frontend/shared/utils/heal.ts
T
3dtours 57ade27eee web: paste the borrowed patch at the light of the place it lands in
HEAL borrows a patch of the photo and copies it over the dust. The copy brings
the patch's texture — which is the point, the repair is the same picture rather
than a blur over the speck — and it also brings the LIGHT the patch was
photographed in, which is not the point at all. The search already refuses a
donor from another light: LIGHT_GATE is 20 levels, and a candidate past that is
not scored. But inside the gate a patch can still be 20 levels off, and 20 levels
is a soft blotch of its own at the rim of the circle — a mark where the dust
used to be, which is what the user is complaining about when they say the repair
is visible. On skin, sky and sand the dust is not the problem the eye finds; the
step the paste puts down is.

So the paste is now the patch's gradients worn at the destination's level: the
shift is the mean of the ring the spot sits in minus the mean of the same ring
around the patch it borrowed, and what lands is the borrowed pixels plus that.
It is the membrane half of a Poisson edit — keep the texture, adopt the level —
and it is eight taps per spot inside the shader that was already running. No
solve, no ping-pong, no extra pass: the correction is recomputed from the
snapshot inside the shader on every render, so the preview and the export agree
by construction and the recipe carries nothing new. The same spot in a saved
photo opens onto the same repair, because nothing about the correction is stored.

Where the ring is measured turned out to be the whole of the change. The first
cut read it at the feather line, 0.85 of the radius, which is where the pasted
patch is still at full strength and therefore looks like the natural place to
compare — but that ring sits just inside the circle, and when the brush fits the
speck snugly, which is exactly how a dust brush is used, it reads the speck: the
light the repair is measured against is then the dust's own, and the patch gets
shifted onto the very dark it exists to erase. It also reversed the smoothstep
edges the moment the ring was pushed outside the brush (a radius past rad, edges
the wrong way round, and the pass quietly drew nothing). The ring now sits just
OUTSIDE the brush, at RING_R of the radius — the same radius the search reads a
spot's light at. Outside, both sides are photographs: the ground the repair has
to sit in, and the ground the patch came from. That the two are the same
measurement is the point: a donor that passed the gate was already within
LIGHT_GATE of this ring, so the shift it now receives is bounded by the gate. The
decision to borrow and the correction to the borrow stopped being two different
opinions about the same pixel.

Eight taps at the same angles on both sides is what makes the difference read as
light rather than as texture: the grain, the detail and the neighbouring specks
that differ between two patches are averaged out by sampling both rings at the
same places, and what is left is the level. The rim, measured as the level inside
the circle against the level of the ground outside it, drops from 20 levels to 0
on a scene built for it, while the borrowed contrast stays at 40 — the level
moved and the gradients did not. That is the line between this and a blur, and it
is the line the lab holds it to.

Verified:
  heal-seam-lab.cjs (scratchpad, CanvasKit, no browser) — 10 PASS, 0 FAIL: one
    scene, the speck on the grey ground with every reachable patch inside a block
    20 levels darker, run twice through the real pipeline — the paste the branch
    shipped before this change (the copy, kept inline in the lab as the "before")
    against healSkSL from the bundled heal.ts. The copy puts the block's own
    level down at the rim: inner 100/110/120 against outer 120/130/140, rim step
    20.0 levels, contrast 40. The shift lands the borrowed texture on the
    ground's level: inner 120/130/140 against outer 120/130/140, rim step 0.0
    levels, contrast 40 — the borrowed feature is still pasted at the strength it
    was borrowed at, the hole reads as the ground it sits in, the block the patch
    came from is untouched, and the frame away from the repair is the photo.
  heal-skia-lab.cjs 28 PASS / 0 FAIL against the bundled module: the pass still
    runs, the uniform block is the size its shader declares, and the paste is
    still an exact copy of the source pixels — the lab's paste scene now borrows
    from the SAME light (a white pixel at the middle of the borrowed patch, so a
    copy and a blur of the dust cannot be confused), and its forty-spot and
    three-spot runs still draw every spot in order. The scene where the two
    lights differ is the seam lab's.
  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 shift is one number per spot, measured over the rim, so a border
the two patches disagree about along its length is only matched on average — a
repair laid across a hard edge keeps a faint step where the edge crosses its rim,
and the other half of the Poisson solve (a correction that bends inside the
circle, a Jacobi solve over the spot's own box, a ping-pong pass per spot) lands
only when a real photo shows that step and the eye can find it. The gate is still
needed and still refuses: a shift corrects a light, it cannot invent a patch
where no patch of that light exists, so a speck surrounded by dust from another
light is left alone rather than covered with a guess. The source is still found
by the ring search — eight directions at three distances, each mirrored — and not
by PatchMatch: the search already refuses dust and wrong light, and PatchMatch
lands when a real photo shows the search picking a bad donor. The run is still
drawn spot by spot, with no stroke id in the recipe, so a long drag is a row of
circles rather than one region.
2026-09-23 22:10:33 +07:00

287 lines
14 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, the same radius the
// search reads a spot's light at), 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. Eight taps are spread around it, at the SAME eight places on both
// sides, so the grain and the detail that differ between two patches average
// out of the difference and what is left is the light: the shift the patch has
// to be pasted with to carry this photo's colour and lighting instead of the one
// it was borrowed from. Reading it at the same radius the search gates on is
// what makes the two agree: a candidate that passed the gate was already within
// LIGHT_GATE of this ring, so the shift it now gets is bounded by it.
const BLEND_TAPS = 8;
// 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.
//
// ponytail: the shift is ONE number per spot, taken over the rim, so a border
// the two patches disagree about along its length is only matched on average —
// a repair across a hard edge keeps a faint step where the edge crosses its rim.
// The other half of the Poisson solve (a correction that bends inside the
// circle, a Jacobi solve over the spot's own box) is a ping-pong pass per spot
// and buys nothing on the skin, sky and sand this tool is aimed at. It lands
// when a hard edge through a repair shows up as a step the eye can find.
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};
half3 shift = half3(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;
shift += img.eval(pd + o).rgb - img.eval(ps + o).rgb;
}
shift *= 1.0 / float(${BLEND_TAPS});
half m = half(1.0 - smoothstep(r, rad, d));
c = mix(c, img.eval(pos + ps - pd) + half4(shift, 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;
}