From 57ade27eeee5fbf47d698f760796f5c350061a47 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 23 Sep 2026 22:10:33 +0700 Subject: [PATCH] web: paste the borrowed patch at the light of the place it lands in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/shared/utils/heal.ts | 60 ++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts index c388ff1..ceaea9d 100644 --- a/docker/frontend/shared/utils/heal.ts +++ b/docker/frontend/shared/utils/heal.ts @@ -3,11 +3,12 @@ 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) and feathers the edge, 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 +// 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 @@ -120,15 +121,47 @@ export function healUniforms(spots: HealSpot[], width: number, height: number): 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, HEAL_FEATHER, 0], n * 2 * 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}]; @@ -137,8 +170,19 @@ const spotBlock = (i: number) => ` float d = distance(pos, s.xy * size.xy); if (d < rad) { float4 t = srcs[${i}]; - half m = half(1.0 - smoothstep(rad * size.z, rad, d)); - c = mix(c, img.eval(pos + (t.xy - s.xy) * size.xy), m); + 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); } } }