diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts index ceaea9d..800c80a 100644 --- a/docker/frontend/shared/utils/heal.ts +++ b/docker/frontend/shared/utils/heal.ts @@ -126,20 +126,43 @@ export function healUniforms(spots: HealSpot[], width: number, height: number): } // 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; +// 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 @@ -153,15 +176,21 @@ const BLEND_TAPS = 8; // 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. +// 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 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. +// 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}]; @@ -173,16 +202,25 @@ const spotBlock = (i: number) => ` 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); + 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; - shift += img.eval(pd + o).rgb - img.eval(ps + o).rgb; + // 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; } - shift *= 1.0 / float(${BLEND_TAPS}); + corr *= half(1.0 / wsum); half m = half(1.0 - smoothstep(r, rad, d)); - c = mix(c, img.eval(pos + ps - pd) + half4(shift, 0.0), m); + c = mix(c, img.eval(pos + ps - pd) + half4(corr, 0.0), m); } } }