diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts index e2a0c57..1daefd3 100644 --- a/docker/frontend/shared/utils/heal.ts +++ b/docker/frontend/shared/utils/heal.ts @@ -39,10 +39,30 @@ export const HEAL_SPACING = 0.6; // 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 patch" when two patches are compared: the centre -// and eight points on a ring at half the radius — the scale the eye reads a -// spot's surroundings at. -const PATCH_TAPS: [number, number][] = [ +// 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], @@ -52,7 +72,22 @@ const PATCH_TAPS: [number, number][] = [ [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); @@ -146,9 +181,23 @@ half4 main(float2 pos) { // 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. The winner is the one whose surroundings look most like the spot's. -// Returns null when the frame is too small to hold any candidate: the caller -// then leaves the spot where it is rather than inventing a patch off the photo. +// 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, @@ -157,14 +206,31 @@ export function findHealSource( ): { 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 score = (cx: number, cy: number) => { - let sum = 0; - for (const [dx, dy] of PATCH_TAPS) { - const a = sample(clamp01(x + dx * r), clamp01(y + dy * r)); - const b = sample(clamp01(cx + dx * r), clamp01(cy + dy * r)); - sum += (Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b)) / 3; + 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; } - return sum / PATCH_TAPS.length; + 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++) { @@ -178,6 +244,7 @@ export function findHealSource( ]) { 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. diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 2c95e42..de9dfec 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -434,7 +434,6 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } } .heal-set { position: absolute; inset: 0; pointer-events: none; } .heal-cursor, -.heal-trail, .heal-spot, .heal-source { position: absolute; @@ -443,9 +442,26 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } pointer-events: none; } .heal-cursor { border: 1px solid rgba(255, 255, 255, 0.9); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55); } -/* The stroke in progress wears the accent, so the run the pointer has drawn - reads as something about to happen rather than a spot already laid. */ -.heal-trail { border: 1px solid var(--accent); } +/* The stroke in progress is one band of the brush's own width — a path with + round ends and round joins — so a drag reads as a brush being drawn across + the photo rather than as the beads a row of overlapping circles looks like. + It wears the accent, so the run reads as something about to happen rather + than a spot already laid. */ +.heal-trail { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} +.heal-trail path { + fill: none; + stroke: var(--accent); + stroke-linecap: round; + stroke-linejoin: round; + stroke-opacity: 0.45; +} .heal-spot { border: 1px solid rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); } .heal-source { border: 1px dashed rgba(255, 255, 255, 0.7); } /* The mixer's readout: the sampled swatch, its RGB and HSL, and the band the diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index b2670ae..3300a9c 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -206,12 +206,13 @@ export function ImageStage({ // The histogram overlay is shown by default and dismissed from its own frame // or from the toolbar button; it steps aside while the crop frame is up. const [showHist, setShowHist] = useState(true); - // Where the eyedropper's own icon is drawing itself inside the photo: the - // layer is the photo's box, so plain pixels are enough. - const [pickPos, setPickPos] = useState<{ x: number; y: number } | null>(null); - // Where the heal brush's circle is drawing itself, same coordinates as the - // eyedropper's icon: plain pixels inside the photo's own box. - const [healPos, setHealPos] = useState<{ x: number; y: number } | null>(null); + // Where the eyedropper's own icon is drawing itself: a fraction of the photo, + // which is what the layer is, so the stage's zoom cannot scale the offset a + // second time and leave the icon drifting away from the cursor. + const [pickPos, setPickPos] = useState<{ fx: number; fy: number } | null>(null); + // Where the heal brush's circle is drawing itself, in the same fraction: the + // ring follows the pointer at every zoom instead of sliding by the zoom factor. + const [healPos, setHealPos] = useState<{ fx: number; fy: number } | null>(null); // The colour that was read is rarely a clear patch of photo, so the mixer's // card can be dragged off the point it hangs on. The offset is a fraction of // the photo — which is what the layer is — so a zoom keeps the card where it @@ -401,12 +402,10 @@ export function ImageStage({ }; const healMove = (e: React.PointerEvent) => { - const r = e.currentTarget.getBoundingClientRect(); - setHealPos({ x: e.clientX - r.left, y: e.clientY - r.top }); - const stroke = strokeRef.current; - if (!stroke) return; const p = healPoint(e.clientX, e.clientY); - if (!p) return; + setHealPos(p ? { fx: p.fx, fy: p.fy } : null); + const stroke = strokeRef.current; + if (!stroke || !p) return; const last = stroke.pts[stroke.pts.length - 1]; // The spacing is a fraction of the brush, measured in the photo's own // pixels: any closer and the run is the same repair written twice. @@ -422,14 +421,17 @@ export function ImageStage({ if (!stroke || !onHealSpots) return; const reader = await healReader(); if (!reader) return; - // No candidate patch means no honest repair: that spot then borrows its own - // pixels, which is a way of doing nothing rather than a blind guess. - onHealSpots( - stroke.pts.map((p) => { - const src = findHealSource(reader, p.fx, p.fy, healR); - return { x: p.fx, y: p.fy, r: healR, sx: src ? src.sx : p.fx, sy: src ? src.sy : p.fy }; - }) - ); + // A spot whose patch the search refuses is not laid down at all: borrowing + // the spot's own pixels would be a repair that changes nothing, and pasting + // a patch from another light would show as a mark of its own, which is worse + // than the speck. The speck stays; the user tries another size or another + // spot. A stroke that is refused end to end reports nothing, and the caller + // records no step for it. + const spots = stroke.pts.flatMap((p) => { + const src = findHealSource(reader, p.fx, p.fy, healR); + return src ? [{ x: p.fx, y: p.fy, r: healR, sx: src.sx, sy: src.sy }] : []; + }); + onHealSpots(spots); }, [healR, healReader, onHealSpots]); // The wheel is the brush's size. It is bound natively rather than through @@ -869,8 +871,15 @@ export function ImageStage({ }} onPointerDown={(e) => e.stopPropagation()} onPointerMove={(e) => { - const r = e.currentTarget.getBoundingClientRect(); - setPickPos({ x: e.clientX - r.left, y: e.clientY - r.top }); + // A fraction of the photo's own box, for the reason the heal + // ring is drawn that way: the layer carries the stage's zoom. + const img = imgRef.current; + const r = img?.getBoundingClientRect(); + setPickPos( + r && r.width > 0 + ? { fx: (e.clientX - r.left) / r.width, fy: (e.clientY - r.top) / r.height } + : null + ); }} onPointerLeave={() => setPickPos(null)} onClick={pickAt} @@ -881,8 +890,8 @@ export function ImageStage({ className="pick-icon" data-key="pick-icon" style={{ - left: pickPos?.x ?? box.width / 2, - top: pickPos?.y ?? box.height / 2, + left: pickPos ? `${pickPos.fx * 100}%` : '50%', + top: pickPos ? `${pickPos.fy * 100}%` : '50%', }} > 1 ? ( + - ))} + data-points={healTrail.length} + viewBox={`0 0 ${box.width} ${box.height}`} + aria-hidden="true" + > + `${i ? 'L' : 'M'} ${t.fx * box.width} ${t.fy * box.height}`).join(' ')} + style={{ strokeWidth: 2 * healR * box.width }} + /> + + ) : null} {healSpots.map((s, i) => { // r is a fraction of the photo's WIDTH, so the circle is round // whatever the picture's shape is — the same measurement the @@ -965,8 +977,8 @@ export function ImageStage({ className="heal-cursor" data-key="heal-cursor" style={{ - left: healPos.x, - top: healPos.y, + left: `${healPos.fx * 100}%`, + top: `${healPos.fy * 100}%`, width: 2 * healR * box.width, height: 2 * healR * box.width, }}