From b795517d9f6c97a06fb615d4f3993d251c8150c7 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 23 Sep 2026 21:36:33 +0700 Subject: [PATCH] web: read a patch's light before pasting it, and paint with the brush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brush was not healing: clicking a speck deleted one black spot and made another, and the borrowed patch landed in a light the spot was not in, so the repair read as a mark of its own. The circle the brush draws also slid off to the side of the pointer as soon as the photo was zoomed in, and a drag showed itself as a row of overlapping circles rather than as a brush being drawn. The search was comparing the wrong thing. findHealSource scored a candidate against the spot's own PATCH_TAPS — the centre and a ring at half the radius, which is INSIDE the brush, where the dust is. The patch that matches a speck best is then the one carrying a speck of its own, which is exactly how "heal a spot" became "move it a few pixels": with a neighbour sitting at the 2.6r ring the search itself prefers, the winner was that neighbour, 26 dark pixels pasted where the repair was meant to be. The taps are split now, by what they are for. The light a repair has to sit in is read off the spot's RING — twelve taps at 1.15r, just outside the dust, the scale the eye reads a spot's surroundings at — and taken as their MEDIAN, because the ring can only be a little way out: some of its taps land on the speck's own softened edge, and a mean drags the whole light down by them (the eight-tap mean read 84 where the ground was 150, and with the gate below that refused every candidate on the frame). What a candidate would actually paste is the mean of its own inside taps, now including the ring at HEAL_FEATHER of the radius — the circle is copied at full strength out to there, so that is where a neighbour's dust leaking into the patch shows up and the middle of the patch would never see it — and its cleanliness is how much those taps spread around their own mean: dust is an outlier in its own neighbourhood, grain is not. A candidate from another light is not scored at all. Past LIGHT_GATE (20 levels of the 0-255 the sampler answers in) the patch IS the mark the user is complaining about, so the search returns null rather than sending a wrong clone and the caller leaves the speck alone. Within the gate the score is light * 3 + cleanliness, so the light decides and cleanliness breaks the ties the eye would not see. A spot the search refuses is not laid down at all — healUp skips it instead of recording a self-patch, which was a repair that changed nothing — and a stroke that is refused end to end reports no spots, which addHealSpots already treats as nothing to do: no step in the history, no spot on the photo. The ring had to be a fraction, not an offset. healPos was the pointer's pixels inside the layer, and the layer carries the stage's transform, so a zoom scaled that offset a second time: at 1:1 the pointer sat at screen x 846.5 and the ring was drawn at 1288 — 442px away, the same distance the user sees as "the circle is in the wrong place when I zoom in". The pointer is stored as a fraction of the photo now — healPoint already answers one for the spot it lays — and drawn as a percentage of the layer, so the layer's own transform scales it once; off the photo there is no ring. The eyedropper's icon had the same shape of bug (its sample was always right — pickAt reads the photo's own rect) and got the same fix in the same file, since it was two lines. The stroke is one mark of the brush. The trail was a circle per point of travel, laid one HEAL_SPACING (0.6) radii apart, which is what a row of beads looks like; it is one SVG path with round caps and round joins now, its width the brush's own diameter and its colour the accent at 45%, so what the pointer draws reads as the band it is about to lay down. The count of travel is kept on the element (data-points) so the probe can still hold the run it becomes to the run it showed. Verified: heal-search-lab.cjs (scratchpad, Node against the bundled heal.ts) — 15 PASS, 0 FAIL: one speck alone is repaired, from a patch that is clean field, and its light is 0.0 levels off the spot's own; a speck with a neighbour exactly at the search's first ring borrows from the far side with 0 dark pixels pasted; a speck ringed with dust in all eight directions skips past the ring (0 pasted); a speck in the corner stays inside the frame; a speck at the lip of a shadow, where every reachable patch is 60 against a ground of 150, is refused (null); ground with a dark edge through it is not a refusal — the repair comes from the light side and its light is 0.0 levels off. heal-skia-lab.cjs — 27 PASS, 0 FAIL (the shader and the search unchanged in everything the search is not asked here). heal-probe.cjs (the rebuilt app at http://localhost:8090) — 49 PASS, 0 FAIL, no page errors: the circle rides the pointer at the size the chip reads; one click heals a speck to 151 with its four neighbours field; the borrowed patch is a real distance away and is clean field; a drag shows ONE mark, 6.1px wide against a 6.1px brush, standing for 15 points of travel, lays exactly 15 spots, clears on release, and UNDO takes the whole stroke back; 25 spots carried with the first healed speck still first; everything gone after a reload; CLEAR brings it all back. heal-zoom-geom.cjs — 5 PASS, 0 FAIL: at fit and at 1:1 the ring's screen centre is the pointer (846.5,452.5 both times, against 1288 before), the ring keeps the brush's size on screen, and a repair made at a zoom lands under the pointer. heal-zoom-probe.cjs — 8 PASS, 0 FAIL: on a structured 2048px photo at 1:1 the speck goes, the donor is at least a ring away, the patched circle is within 1.07 levels of the ground it landed on, the donor's own circle is drawn on the pixels it borrowed; on a navy field with three specks, two repairs land 0.0 levels from their ground. heal-look2.cjs (scratchpad, PNGs in /home/locpham): the pair case used to paste its neighbour and read min 3 inside the healed circle — the pasted dust — and reads 151 now, the untouched second speck alone in the frame; the big-speck case (dust r=9 under a 6px brush) now lays NO spot at all, which is the refusal working: the speck is left alone instead of smeared. Regressions against the rebuilt app, 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. web tsc --noEmit clean. ponytail: a refusal leaves the speck on the photo and nothing on the screen — the user closes the brush in a size that covers it and clicks again — which is the honest half of the trade the user asked for, but it is silent; a hint would mean a toast or a shake, and neither is worth a component. The gate is a flat 20 levels, not a percentage of the local contrast, so a photo with a hard edge through the brush's own ring reads as one light and can still take a donor from the other side of it. The search reads the preview JPEG rather than the original, so a patch near the preview's own edges is chosen from the pixels the user is looking at, not from the ones the export will print. And the run a stroke leaves behind is still drawn as its spots, circle by circle, because each one is a repair with a borrowed patch of its own — drawing the laid run as one band would need the recipe to remember the gesture (a stroke id on the spots), which is a recipe change and not what was asked. --- docker/frontend/shared/utils/heal.ts | 95 +++++++++++++++++++++++---- docker/frontend/src/styles/app.css | 24 +++++-- docker/frontend/src/ui/ImageStage.tsx | 86 +++++++++++++----------- 3 files changed, 150 insertions(+), 55 deletions(-) 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, }}