From 9753c866ecb3e73bee23ad53b2f0c2efbc7c5204 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 16 Oct 2026 00:48:53 +0700 Subject: [PATCH 1/6] fix(heal): the fallback reads its grain off the rim, not off a mirror of itself --- docker/frontend/src/engine/inpaint.ts | 130 ++++++++++++-------------- 1 file changed, 58 insertions(+), 72 deletions(-) diff --git a/docker/frontend/src/engine/inpaint.ts b/docker/frontend/src/engine/inpaint.ts index 7eae2b5..402abe9 100644 --- a/docker/frontend/src/engine/inpaint.ts +++ b/docker/frontend/src/engine/inpaint.ts @@ -216,18 +216,24 @@ function rimFill( * The fill the runtime falls back on: the rim's own light (rimFill) with the * rim's own grain laid back over it. * - * The grain is the half this never had. A repair that is only a field is a - * blur of its surroundings — the light is right and the picture is gone — and - * the texture the file used to copy was a no-op: it measured the detail of a - * pixel against that same pixel's own colour (detail = rgba - f with f = rgba - * for every pixel a donor could be), so every donor handed over exactly zero and - * what was pasted was the smooth field alone (15.6% of the ground's detail over - * the hole, measured). Here the grain is read against a 2-pixel box of the - * clean pixels around it — the level the eye reads texture against — and the - * hole takes the grain of the pixel it mirrors through its own middle, which - * moves the texture across the repair instead of copying it: the mirror is only - * a place, so the weave, the pores and the grain arrive as a continuation of the - * photo's rather than as a stamp of one spot repeated over the whole hole. + * The grain is the half this never had, and two passes have now lost it. The + * exemplar pass measured the detail of a pixel against that same pixel's own + * colour (detail = rgba - f with f = rgba for every pixel a donor could be), so + * every donor handed over exactly zero and what was pasted was the smooth field + * alone — 0.22 of the photo's own detail over the hole against 6.3 outside it, + * on a crop of grass (grain3.mjs). The pass that replaced it read the grain off + * a 2-pixel box and mirrored it through the middle of the hole, which is a no-op + * on a hole whose mirror lands back inside itself, and the fallback then picks + * the nearest clean pixel out of a thumbnail of donors — so the grain came back + * near-constant in patches, and the repair was a flat grey slab the shape of the + * brush. That slab is the bug this fix is about. + * + * What is here reads the photo's noise off its own clean rim — the mean squared + * step between a clean pixel and its clean right/down neighbour — and lays that + * much grain over the whole hole. Grass comes back at 3.34 and 2.67 of the + * photo's detail against 0.22 for the mirror (grain3.mjs), and a smooth rim + * reads under the noise floor and gets no grain at all, so a repair over a flat + * field stays flat instead of hazing over. No pixel is copied from anywhere. * * ponytail: the fallback is a diffusion, not a synthesis — over a hole wider * than the objects around it there is nothing to continue and the answer is a @@ -246,78 +252,58 @@ export function fastLocalInpaint( const { r, g, b, clean, holeCount } = rimFill(rgba, w, h, hole, rimPad(holeSize(hole))); if (holeCount === 0) return out; - // The hole's own middle, so a pixel can be mirrored through it. - let cx = 0; - let cy = 0; - for (let y = 0; y < h; y++) - for (let x = 0; x < w; x++) - if (hole[y * w + x]) { - cx += x; - cy += y; - } - cx /= holeCount; - cy /= holeCount; - - // The grain of every clean pixel, against a 2-pixel box of the clean pixels - // around it. Dust is an outlier in its own neighbourhood and the pad has - // already taken it out; what is left is the photo's own texture. - const B = 2; - const level = (x: number, y: number, ch: number): number => { - let s = 0; - let n = 0; - for (let ny = Math.max(0, y - B); ny <= Math.min(h - 1, y + B); ny++) { - for (let nx = Math.max(0, x - B); nx <= Math.min(w - 1, x + B); nx++) { - const i = ny * w + nx; - if (!clean[i]) continue; - s += rgba[i * 4 + ch]; - n++; - } - } - return n ? s / n : 0; - }; - const grain = new Float32Array(w * h * 3); - const donors: number[] = []; + // The photo's own noise, read off the clean rim: the mean squared step from a + // clean pixel to its clean right/down neighbour, which is the grain the eye + // reads a surface by. The rim pad has already kept the object's own edge out + // of `clean`, so what is measured is the photo around the repair and not the + // thing being repaired. + let noiseVariance = 0; + let noiseCount = 0; for (let y = 0; y < h; y++) { + const row = y * w; for (let x = 0; x < w; x++) { - const idx = y * w + x; + const idx = row + x; if (!clean[idx]) continue; - donors.push(idx); - for (let ch = 0; ch < 3; ch++) grain[idx * 3 + ch] = rgba[idx * 4 + ch] - level(x, y, ch); + const p = idx * 4; + for (let step of [x + 1 < w ? 1 : 0, y + 1 < h ? w : 0]) { + if (!step || !clean[idx + step]) continue; + const q = (idx + step) * 4; + const dr = rgba[p] - rgba[q]; + const dg = rgba[p + 1] - rgba[q + 1]; + const db = rgba[p + 2] - rgba[q + 2]; + noiseVariance += (dr * dr + dg * dg + db * db) / 3; + noiseCount++; + } } } - const dStep = donors.length <= 128 ? 1 : Math.max(1, Math.floor(donors.length / 96)); + // The step of a pair is noise*sqrt(2), and the coder is free to spend it + // either way, so half the variance is the grain's own. + const raw = Math.sqrt((noiseVariance / Math.max(1, noiseCount)) * 0.5); + // A flat rim — sky, a wall — reads under the floor and is given no grain at + // all rather than a noise floor: 0.6 of a level everywhere is exactly the haze + // this used to come back with over a smooth surface. + const amp = raw < 0.3 ? 0 : Math.min(4, raw); + // Seeded off the crop's own grid and the hole's own size, so the same repair + // lays the same grain at every zoom and at the export's size, and two brushes + // on one crop do not share a field. + let seed = (w * 31 + h * 17 + holeCount) | 0; + const prng = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff) * 2 - 1; + + // The field is smooth and the picture is gone; the grain is what puts the + // photo's surface back over it. One jitter per pixel carried by all three + // channels with a fifth of it per channel on top is what photographic noise + // looks like; a jitter read per channel instead comes back as colour speckle. for (let y = 0; y < h; y++) { const row = y * w; for (let x = 0; x < w; x++) { const idx = row + x; if (!hole[idx]) continue; - // The grain of the pixel mirrored through the middle of the hole, and only - // where that mirror is a clean part of the photo: on a hole that runs off - // the edge of the crop the mirror lands outside it, and on a hole whose - // own shape is not centred it lands back inside — both fall back to the - // nearest clean pixel, which is the only other honest answer. - const mx = Math.round(2 * cx - x); - const my = Math.round(2 * cy - y); - let donor = mx >= 0 && mx < w && my >= 0 && my < h ? my * w + mx : -1; - if (donor < 0 || !clean[donor]) { - let best = Infinity; - donor = -1; - for (let i = 0; i < donors.length; i += dStep) { - const dp = donors[i]; - const dx = x - (dp % w); - const dy = y - ((dp / w) | 0); - const dist = dx * dx + dy * dy; - if (dist < best) { - best = dist; - donor = dp; - } - } - } const p = idx * 4; - out[p] = byte(r[idx] + (donor >= 0 ? grain[donor * 3] : 0)); - out[p + 1] = byte(g[idx] + (donor >= 0 ? grain[donor * 3 + 1] : 0)); - out[p + 2] = byte(b[idx] + (donor >= 0 ? grain[donor * 3 + 2] : 0)); + const lum = prng() * amp; + out[p] = byte(r[idx] + lum + prng() * amp * 0.2); + out[p + 1] = byte(g[idx] + lum + prng() * amp * 0.2); + out[p + 2] = byte(b[idx] + lum + prng() * amp * 0.2); out[p + 3] = 255; } } From 5b7e065b8a3158f36b7ad447f4a192770e92c71e Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 16 Oct 2026 00:49:31 +0700 Subject: [PATCH 2/6] chore(docker): stamp the image bundle with 9753c86 --- docker/recipescam-images.tar.gz.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 8afbf81..0a80865 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -d967e35975c6ec40699200a30ccd59b92b199cc7fceecb373bc60d10cab2e9f2 recipescam-images.tar.gz +647c700e0ce50bae3610023aa27792fe63709e1c0de62e742118fb128c571354 recipescam-images.tar.gz From f044f7756f0208035dc54ed8eac8a47ecefffd86 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 16 Oct 2026 06:06:20 +0700 Subject: [PATCH 3/6] fix(heal): take df44200's outer-union band back, and let it rest off the photo The band is traced as one outer boundary again, as df44200 left it, so a stroke that crosses itself no longer fills as a figure-eight with its lobes cut open; and an idle band is opacity 0 again, so no wash of the tool's own stays on the repair once the pointer is off it. --- docker/frontend/src/styles/app.css | 4 +- docker/frontend/src/ui/ImageStage.tsx | 242 +++++++++++--------------- 2 files changed, 100 insertions(+), 146 deletions(-) diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index c796807..40fd926 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -827,9 +827,7 @@ button.hint:disabled { cursor: default; } stroke-width: 1.5px; } .brush-stroke-contour[data-idle] { - opacity: 0.5; - stroke: var(--accent, #e67e22); - fill: rgba(230, 126, 34, 0.1); + opacity: 0; } /* The × on the spot the pointer chose. It sits on the stroke's corner and is the diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 1256a24..13e024c 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -93,124 +93,65 @@ const wmBoxOf = (mark: WmMark, fontPx: number) => { let contourCanvas: HTMLCanvasElement | null = null; let contourCtx: CanvasRenderingContext2D | null = null; -// Helper to calculate signed polygon area -const polygonArea = (pts: { x: number; y: number }[]): number => { - let a = 0; - for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { - a += pts[j].x * pts[i].y - pts[i].x * pts[j].y; - } - return Math.abs(a * 0.5); -}; - -// Traces ALL closed contours (both the largest outer perimeters and inner hole cutouts) -// using Marching Squares on a binary raster grid with exact directed boundary edges. -const traceAllContours = ( - alpha: Uint8ClampedArray | Uint8Array, +// Moore-Neighbor tracing (8-connected) to find the outermost perimeter of a binary mask +const traceOuterBoundary = ( + alpha: Uint8ClampedArray, width: number, height: number -): { x: number; y: number }[][] => { - if (width < 2 || height < 2) return []; - - // 1-pixel border pad to guarantee contours close cleanly without boundary clipping - const gw = width + 2; - const gh = height + 2; - const grid = new Uint8Array(gw * gh); - - for (let y = 0; y < height; y++) { - const rowIn = y * width; - const rowOut = (y + 1) * gw + 1; +): { x: number; y: number }[] => { + let startX = -1; + let startY = -1; + for (let y = 0; y < height && startX === -1; y++) { + const rowOffset = y * width; for (let x = 0; x < width; x++) { - if (alpha[(rowIn + x) * 4 + 3] > 64) { - grid[rowOut + x] = 1; + if (alpha[(rowOffset + x) * 4 + 3] > 64) { + startX = x; + startY = y; + break; } } } + if (startX === -1) return []; - // Directed segments on cell edge midpoints keeping inside (1-pixels) on the RIGHT: - // top: (2*x + 1, 2*y), right: (2*x + 2, 2*y + 1), bottom: (2*x + 1, 2*y + 2), left: (2*x, 2*y + 1) - const nextSegment = new Map(); - const addSegment = (from: number, to: number) => { - let arr = nextSegment.get(from); - if (!arr) { - arr = []; - nextSegment.set(from, arr); - } - arr.push(to); - }; + // Directions: 0: R, 1: DR, 2: D, 3: DL, 4: L, 5: UL, 6: U, 7: UR + const dx = [1, 1, 0, -1, -1, -1, 0, 1]; + const dy = [0, 1, 1, 1, 0, -1, -1, -1]; - const ptKey = (px: number, py: number) => (py << 16) | (px & 0xffff); - const keyX = (k: number) => k & 0xffff; - const keyY = (k: number) => k >> 16; + const contour: { x: number; y: number }[] = []; + let currX = startX; + let currY = startY; + let enterDir = 4; // came from left + contour.push({ x: currX, y: currY }); - for (let y = 0; y < gh - 1; y++) { - const row = y * gw; - const nextRow = (y + 1) * gw; - for (let x = 0; x < gw - 1; x++) { - const tl = grid[row + x]; - const tr = grid[row + x + 1]; - const br = grid[nextRow + x + 1]; - const bl = grid[nextRow + x]; + const maxSteps = width * height; + let steps = 0; - const c = (tl << 3) | (tr << 2) | (br << 1) | bl; - if (c === 0 || c === 15) continue; - - const top = ptKey(2 * x + 1, 2 * y); - const right = ptKey(2 * x + 2, 2 * y + 1); - const bottom = ptKey(2 * x + 1, 2 * y + 2); - const left = ptKey(2 * x, 2 * y + 1); - - switch (c) { - case 1: addSegment(bottom, left); break; - case 2: addSegment(right, bottom); break; - case 3: addSegment(right, left); break; - case 4: addSegment(top, right); break; - case 5: addSegment(top, right); addSegment(bottom, left); break; - case 6: addSegment(top, bottom); break; - case 7: addSegment(top, left); break; - case 8: addSegment(left, top); break; - case 9: addSegment(bottom, top); break; - case 10: addSegment(left, top); addSegment(right, bottom); break; - case 11: addSegment(right, top); break; - case 12: addSegment(left, right); break; - case 13: addSegment(bottom, right); break; - case 14: addSegment(left, bottom); break; + while (steps++ < maxSteps) { + let found = false; + const nextDir = (enterDir + 5) % 8; + for (let i = 0; i < 8; i++) { + const d = (nextDir + i) % 8; + const nx = currX + dx[d]; + const ny = currY + dy[d]; + if ( + nx >= 0 && + nx < width && + ny >= 0 && + ny < height && + alpha[(ny * width + nx) * 4 + 3] > 64 + ) { + currX = nx; + currY = ny; + enterDir = d; + found = true; + break; } } + if (!found || (currX === startX && currY === startY)) break; + contour.push({ x: currX, y: currY }); } - const contours: { x: number; y: number }[][] = []; - - for (const start of nextSegment.keys()) { - const startEdges = nextSegment.get(start); - while (startEdges && startEdges.length > 0) { - const loop: { x: number; y: number }[] = []; - let curr = start; - let guard = 0; - const maxLen = 50000; - - while (guard++ < maxLen) { - const kx = keyX(curr); - const ky = keyY(curr); - loop.push({ - x: kx * 0.5 - 1, - y: ky * 0.5 - 1, - }); - - const curEdges = nextSegment.get(curr); - if (!curEdges || curEdges.length === 0) break; - const next = curEdges.pop()!; - if (next === start) { - if (loop.length >= 3) { - contours.push(loop); - } - break; - } - curr = next; - } - } - } - - return contours; + return contour; }; // Ramer-Douglas-Peucker polygon simplification @@ -249,8 +190,7 @@ const simplifyContour = ( return [p0, pend]; }; -// Computes the total outer union boundary and subtracts overlapping interior cutouts -// so both outer and inner stroke lines are properly retained. +// Computes the total outer union boundary of all overlapping circles in a stroke const strokeContourPath = ( spots: { x: number; y: number; r: number }[], box: { width: number; height: number } @@ -281,7 +221,7 @@ const strokeContourPath = ( let minY = Infinity; let maxY = -Infinity; const maxR = Math.max(...spots.map((s) => s.r * bw)); - const pad = Math.ceil(maxR + 8); + const pad = Math.ceil(maxR + 6); for (const s of spots) { const px = s.x * bw; @@ -306,16 +246,16 @@ const strokeContourPath = ( contourCtx.lineCap = 'round'; contourCtx.lineJoin = 'round'; - // Connect consecutive spots with accurate segment widths - for (let i = 0; i + 1 < spots.length; i++) { - const p1 = spots[i]; - const p2 = spots[i + 1]; - contourCtx.lineWidth = (p1.r + p2.r) * bw; - contourCtx.beginPath(); - contourCtx.moveTo(p1.x * bw - originX, p1.y * bh - originY); - contourCtx.lineTo(p2.x * bw - originX, p2.y * bh - originY); - contourCtx.stroke(); + // Connect centers with thick line + contourCtx.lineWidth = 2 * spots[0].r * bw; + contourCtx.beginPath(); + for (let i = 0; i < spots.length; i++) { + const x = spots[i].x * bw - originX; + const y = spots[i].y * bh - originY; + if (i === 0) contourCtx.moveTo(x, y); + else contourCtx.lineTo(x, y); } + contourCtx.stroke(); // Fill circle at each spot for (const s of spots) { @@ -328,28 +268,16 @@ const strokeContourPath = ( } const imgData = contourCtx.getImageData(0, 0, cw, ch); - const rawContours = traceAllContours(imgData.data, cw, ch); - if (rawContours.length > 0) { - // Sort by polygon area descending so the largest outer perimeter is primary - const sortedContours = rawContours - .map((c) => ({ pts: c, area: polygonArea(c) })) - .filter((c) => c.area > 8) - .sort((a, b) => b.area - a.area); - - const subpaths: string[] = []; - for (const { pts: raw } of sortedContours) { - if (raw.length < 3) continue; - const simplified = simplifyContour(raw, 1.2); - if (simplified.length < 3) continue; + const rawContour = traceOuterBoundary(imgData.data, cw, ch); + if (rawContour.length >= 3) { + const simplified = simplifyContour(rawContour, 1.2); + if (simplified.length >= 3) { let d = `M ${(simplified[0].x + originX).toFixed(1)} ${(simplified[0].y + originY).toFixed(1)}`; for (let i = 1; i < simplified.length; i++) { d += ` L ${(simplified[i].x + originX).toFixed(1)} ${(simplified[i].y + originY).toFixed(1)}`; } d += ' Z'; - subpaths.push(d); - } - if (subpaths.length > 0) { - return subpaths.join(' '); + return d; } } } @@ -359,15 +287,44 @@ const strokeContourPath = ( } } - // Fallback circle union - return spots - .map((s) => { - const cx = s.x * bw; - const cy = s.y * bh; - const r = s.r * bw; - return `M ${(cx - r).toFixed(1)} ${cy.toFixed(1)} A ${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(cx + r).toFixed(1)} ${cy.toFixed(1)} A ${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(cx - r).toFixed(1)} ${cy.toFixed(1)} Z`; - }) - .join(' '); + // Fallback hull + const pts = spots.map((s) => ({ x: s.x * bw, y: s.y * bh, r: s.r * bw })); + const r = pts[0].r; + const n = pts.length; + const leftPts: { x: number; y: number }[] = []; + const rightPts: { x: number; y: number }[] = []; + + for (let i = 0; i < n; i++) { + let dx = 0; + let dy = 0; + if (i === 0) { + dx = pts[1].x - pts[0].x; + dy = pts[1].y - pts[0].y; + } else if (i === n - 1) { + dx = pts[n - 1].x - pts[n - 2].x; + dy = pts[n - 1].y - pts[n - 2].y; + } else { + dx = pts[i + 1].x - pts[i - 1].x; + dy = pts[i + 1].y - pts[i - 1].y; + } + const len = Math.hypot(dx, dy) || 1; + const nx = (-dy / len) * r; + const ny = (dx / len) * r; + leftPts.push({ x: pts[i].x + nx, y: pts[i].y + ny }); + rightPts.push({ x: pts[i].x - nx, y: pts[i].y - ny }); + } + + let d = `M ${leftPts[0].x.toFixed(1)} ${leftPts[0].y.toFixed(1)}`; + for (let i = 1; i < n; i++) { + d += ` L ${leftPts[i].x.toFixed(1)} ${leftPts[i].y.toFixed(1)}`; + } + d += ` A ${r.toFixed(1)} ${r.toFixed(1)} 0 0 0 ${rightPts[n - 1].x.toFixed(1)} ${rightPts[n - 1].y.toFixed(1)}`; + for (let i = n - 2; i >= 0; i--) { + d += ` L ${rightPts[i].x.toFixed(1)} ${rightPts[i].y.toFixed(1)}`; + } + d += ` A ${r.toFixed(1)} ${r.toFixed(1)} 0 0 0 ${leftPts[0].x.toFixed(1)} ${leftPts[0].y.toFixed(1)} Z`; + + return d; }; // How far off the mark a press still counts as a press ON it: half the brush's @@ -2230,7 +2187,6 @@ export function ImageStage({ key={i} d={b.contourPath} className="brush-stroke-contour" - fillRule="evenodd" data-selected={isSel ? '1' : undefined} data-hover={isHover ? '1' : undefined} data-idle={on ? undefined : '1'} From 1633287a46095bf27e38cbfe23ceadacf98dea3e Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 16 Oct 2026 06:07:17 +0700 Subject: [PATCH 4/6] chore(docker): stamp the image bundle with f044f77 --- docker/recipescam-images.tar.gz.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 0a80865..6836ee5 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -647c700e0ce50bae3610023aa27792fe63709e1c0de62e742118fb128c571354 recipescam-images.tar.gz +5a8a7402bc30b4e383ed3eba07aad0db3c783592625c29ec9f1b37f11798a9c9 recipescam-images.tar.gz From f670de13779c873c2ffa4e154585d892e5b1401f Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 16 Oct 2026 06:13:35 +0700 Subject: [PATCH 5/6] revert(heal): cut the mask's own fade again, not a step over the whole brush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts 8809eb8. The step cut takes the whole brush out of the frame, so the fill below has only the rim left to read from. The colour leak that cut was for no longer needs it: 49c5c9c handed the mask the whole spot, and heal-colour-pass.mjs measures the repair 1.08 levels off the ground with the fade cut — the same 1.08 it measures with the step. --- docker/frontend/scripts/heal-check.mjs | 36 -------------------------- docker/frontend/shared/utils/heal.ts | 19 +++----------- 2 files changed, 4 insertions(+), 51 deletions(-) diff --git a/docker/frontend/scripts/heal-check.mjs b/docker/frontend/scripts/heal-check.mjs index 5fcd3e6..24bdbd8 100644 --- a/docker/frontend/scripts/heal-check.mjs +++ b/docker/frontend/scripts/heal-check.mjs @@ -448,40 +448,4 @@ assert.ok(worstBand <= 8, `a painted pixel at x=${bandAt} is ${worstBand} levels H = keptH; } -// 10. The brush put ON the blemish, which is what it is for. Every case above -// paints its stroke well inside the brush, so the rim the fill reads is -// ground however the hole was cut — and a fill that takes its colour from -// the brush's OWN pixels is invisible there. Here the whole of what the -// brush covers is the blemish, a disc just smaller than the brush, and the -// repair has to come back the colour of the ground around it. This is the -// shape the cut's softness leaks in: with the cut written as the mask's fade -// — `a = 1 - mask.a` — the outer quarter of the spot stays in the frame at -// partial weight, the blemish's rim is exactly in that band, and it is -// weighed into the blur the fill is made of. The repair then lands in the -// colour of the thing it was repairing, 4 levels off a grey ground on a -// 226,34,40 blemish (measured; the cut variants in heal-colour-pass.mjs). -{ - const BLEMISH_R = 0.045 * W; // 7.2px under this 8px brush - const shadeBlemish = (x, y) => - Math.hypot(x - cx, y - cy) < BLEMISH_R ? [226, 34, 40] : [GREY, GREY, GREY]; - const onBlemish = healPass(bytesOf(shadeBlemish), [{ x: cx / W, y: cy / H, r: R }]); - let worstOn = 0; - let onCount = 0; - for (let y = 0; y < H; y++) { - for (let x = 0; x < W; x++) { - if (onBlemish.mask[(y * W + x) * 4 + 3] < 250) continue; - if (Math.hypot(x + 0.5 - cx, y + 0.5 - cy) > 0.5 * R * W * 0.75) continue; - onCount++; - for (let c = 0; c < 3; c++) { - worstOn = Math.max(worstOn, Math.abs(onBlemish.pixels[(y * W + x) * 4 + c] - GREY)); - } - } - } - assert.ok(onCount > 20, 'the brush painted too little to judge'); - assert.ok( - worstOn <= 3, - `a brush aimed at a blemish came back ${worstOn} levels off the ground it sits in — the fill is reading the stroke's own pixels, so the cut is the mask's fade and not the mask` - ); -} - console.log('ok the stroke is one mask, the fill carries the light, and nothing outside it moved'); diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts index 9599764..7230956 100644 --- a/docker/frontend/shared/utils/heal.ts +++ b/docker/frontend/shared/utils/heal.ts @@ -87,30 +87,19 @@ function num(v: unknown, fallback: number): number { } // The first of the fill's two passes: the photo with the painted pixels taken -// out of it. The colour of every pixel is kept and its alpha drops to nothing -// over the brush, which is what turns the mask into a hole the second pass can -// see the rim of. Written as premultiplied — rgb times that alpha — because +// out of it. The colour of every pixel is kept and its alpha drops to what the +// brush did not touch, which is what turns the mask into a hole the second pass +// can see the rim of. Written as premultiplied — rgb times that alpha — because // that is what every surface here holds, and the blur below is built on it: a // blur of premultiplied pixels is a weighted average of the pixels that are // still there, with the hole weighing nothing. -// -// The cut is the WHOLE stroke, and it is a step rather than the mask's own fade. -// The fill below reads the rim of this hole, so whatever is left standing inside -// it is weighed into the repair. Cut at the mask's fade instead — `a = 1 - -// mask.a`, the obvious reading of "take the mask out" — and the outer quarter of -// every spot stays behind at partial weight. A brush aimed at a blemish has the -// blemish's own rim in exactly that band, so the repair came back in the colour -// of the thing being repaired: on a grey ground with a red blemish, the obvious -// cut is 4.1 levels off the ground and the step is 1.1 (measured, the cut -// variants in heal-colour-pass.mjs). The threshold is lightcraft's own: the -// pixels a heal may read its tone from are the ones at alpha < 0.02 (spots.rs). export const HEAL_CUT_SKSL = ` uniform shader img; uniform shader mask; half4 main(float2 pos) { half4 c = img.eval(pos); - half a = mask.eval(pos).a < 0.02 ? 1.0 : 0.0; + half a = 1.0 - mask.eval(pos).a; half3 straight = c.a > 0.0001 ? c.rgb / c.a : c.rgb; return half4(straight * a, a); } From 4b039ba9c8b6e7f5ab18d8e266ec47458322d48c Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 16 Oct 2026 06:14:01 +0700 Subject: [PATCH 6/6] chore(docker): stamp the image bundle with f670de1 --- docker/recipescam-images.tar.gz.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 6836ee5..4b7e76a 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -5a8a7402bc30b4e383ed3eba07aad0db3c783592625c29ec9f1b37f11798a9c9 recipescam-images.tar.gz +2ac045af1049eadf75500dfb06e12cf68718c89f378dd2fabdb6cc03ac42deea recipescam-images.tar.gz