Remove watermarks, spots, and scratches from a photo: paint over the mark and it disappears. Runs in your browser. Nothing uploaded.
---
format: typebulb/v1
name: Magic Eraser
---
**code.tsx**
```tsx
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react"
import { createRoot } from "react-dom/client"
interface Pt { x: number; y: number }
interface Box { x: number; y: number; w: number; h: number }
interface Patch { box: Box; before: Uint8ClampedArray }
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
const BRUSH_MIN = 6, BRUSH_MAX = 120 // brush diameter range, in screen px
// Width of the texture ring sampled around a stroke, in image px.
const ringOf = (brush: number) => clamp(Math.round(brush * 0.6), 4, 24)
function bboxOf(pts: Pt[], pad: number, W: number, H: number): Box {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity
for (const p of pts) { x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y); x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y) }
const bx = clamp(Math.floor(x0 - pad), 0, W - 1), by = clamp(Math.floor(y0 - pad), 0, H - 1)
return { x: bx, y: by, w: clamp(Math.ceil(x1 + pad) + 1 - bx, 1, W - bx), h: clamp(Math.ceil(y1 + pad) + 1 - by, 1, H - by) }
}
// The stroke as a box-local coverage mask (0..255), round caps and joins.
function coverage(pts: Pt[], width: number, box: Box): Uint8Array {
const c = document.createElement("canvas"); c.width = box.w; c.height = box.h
const x = c.getContext("2d", { willReadFrequently: true })!
x.translate(-box.x, -box.y)
x.lineWidth = width; x.lineCap = "round"; x.lineJoin = "round"; x.strokeStyle = "#000"
x.beginPath(); x.moveTo(pts[0].x, pts[0].y)
if (pts.length === 1) x.lineTo(pts[0].x + 0.01, pts[0].y)
for (let i = 1; i < pts.length; i++) x.lineTo(pts[i].x, pts[i].y)
x.stroke()
const a = x.getImageData(0, 0, box.w, box.h).data
const m = new Uint8Array(box.w * box.h)
for (let i = 0; i < m.length; i++) m[i] = a[i * 4 + 3]
return m
}
// Points every `step` px along the path, so pieces have even length however fast the pointer moved.
function resample(pts: Pt[], step: number): Pt[] {
const out: Pt[] = [pts[0]]
let carry = step
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1], b = pts[i], d = Math.hypot(b.x - a.x, b.y - a.y)
if (!d) continue
let t = carry
while (t <= d) { out.push({ x: a.x + (b.x - a.x) * t / d, y: a.y + (b.y - a.y) * t / d }); t += step }
carry = t - d
}
const last = pts[pts.length - 1], o = out[out.length - 1]
if (o.x !== last.x || o.y !== last.y) out.push(last)
return out
}
function copyBox(img: ImageData, b: Box): Uint8ClampedArray {
const out = new Uint8ClampedArray(b.w * b.h * 4)
for (let y = 0; y < b.h; y++) out.set(img.data.subarray(((b.y + y) * img.width + b.x) * 4, ((b.y + y) * img.width + b.x + b.w) * 4), y * b.w * 4)
return out
}
function pasteBox(img: ImageData, b: Box, px: Uint8ClampedArray) {
for (let y = 0; y < b.h; y++) img.data.set(px.subarray(y * b.w * 4, (y + 1) * b.w * 4), ((b.y + y) * img.width + b.x) * 4)
}
// The offset whose shifted copy of the ring best matches the ring itself (SSD over RGB), coarse grid then
// a 1px refine. A shifted mask may not overlap the mask: the blemish must never be its own source.
function bestOffset(data: Uint8ClampedArray, W: number, H: number, box: Box, mask: Uint8Array, maskIdx: Int32Array, ringIdx: Int32Array): [number, number] | null {
const { x: bx, y: by, w: bw, h: bh } = box
const rstride = Math.max(1, Math.ceil(ringIdx.length / 1200))
const rs = new Int32Array(Math.ceil(ringIdx.length / rstride))
for (let k = 0, j = 0; k < ringIdx.length; k += rstride) { const i = ringIdx[k]; rs[j++] = ((by + (i / bw | 0)) * W + bx + (i % bw)) * 4 }
// Overlap is tested on a 4x-downsampled, one-cell-dilated mask: conservative and 16x cheaper.
const cw = (bw + 3) >> 2, ch = (bh + 3) >> 2
const cm = new Uint8Array(cw * ch), cd = new Uint8Array(cw * ch)
let mx0 = bw, my0 = bh, mx1 = 0, my1 = 0
for (let k = 0; k < maskIdx.length; k++) {
const i = maskIdx[k], lx = i % bw, ly = (i / bw) | 0
cm[(ly >> 2) * cw + (lx >> 2)] = 1
if (lx < mx0) mx0 = lx; if (lx > mx1) mx1 = lx; if (ly < my0) my0 = ly; if (ly > my1) my1 = ly
}
const cells: number[] = []
for (let c = 0; c < cm.length; c++) if (cm[c]) {
cells.push(c)
const cx = c % cw, cy = (c / cw) | 0
for (let y = Math.max(0, cy - 1); y <= Math.min(ch - 1, cy + 1); y++) for (let x = Math.max(0, cx - 1); x <= Math.min(cw - 1, cx + 1); x++) cd[y * cw + x] = 1
}
const mw = mx1 - mx0 + 1, mh = my1 - my0 + 1
const R = Math.min(clamp(2 * Math.max(bw, bh), 24, 320), Math.max(W, H))
const step = Math.max(1, Math.ceil(R / 32))
let best = Infinity, bdx = 0, bdy = 0
const tryOff = (dx: number, dy: number) => {
if (!dx && !dy) return
if (bx + dx < 0 || by + dy < 0 || bx + bw + dx > W || by + bh + dy > H) return
if (Math.abs(dx) < mw && Math.abs(dy) < mh) {
for (const c of cells) {
const x = ((c % cw) * 4 + dx) >> 2, y = (((c / cw) | 0) * 4 + dy) >> 2
if (x >= 0 && y >= 0 && x < cw && y < ch && cd[y * cw + x]) return
}
}
// Mild preference for nearby sources: they share the blemish's lighting.
const pen = 1 + 0.2 * Math.hypot(dx, dy) / R, lim = best / pen, shift = (dy * W + dx) * 4
let s = 0
for (let k = 0; k < rs.length; k++) {
const o = rs[k], q = o + shift
const dr = data[o] - data[q], dg = data[o + 1] - data[q + 1], db = data[o + 2] - data[q + 2]
s += dr * dr + dg * dg + db * db
if (s > lim) return
}
best = s * pen; bdx = dx; bdy = dy
}
for (let dy = -R; dy <= R; dy += step) for (let dx = -R; dx <= R; dx += step) tryOff(dx, dy)
if (best === Infinity) return null
if (step > 1) { const cx = bdx, cy = bdy; for (let dy = 1 - step; dy < step; dy++) for (let dx = 1 - step; dx < step; dx++) tryOff(cx + dx, cy + dy) }
return [bdx, bdy]
}
function unknowns(flag: Uint8Array): Int32Array {
const a: number[] = []
for (let g = 0; g < flag.length; g++) if (flag[g] === 2) a.push(g)
return Int32Array.from(a)
}
// SOR sweeps over the unknown cells: omega near 1 smooths, near 2 converges the low frequencies.
function sweeps(flag: Uint8Array, u: Float32Array, gw: number, unk: Int32Array, iters: number, omega: number, tol: number) {
for (let it = 0; it < iters; it++) {
let maxD = 0
for (let j = 0; j < unk.length; j++) {
const g = unk[j]
for (let c = 0; c < 3; c++) {
let s = 0, k = 0
if (flag[g - 1]) { s += u[(g - 1) * 3 + c]; k++ }
if (flag[g + 1]) { s += u[(g + 1) * 3 + c]; k++ }
if (flag[g - gw]) { s += u[(g - gw) * 3 + c]; k++ }
if (flag[g + gw]) { s += u[(g + gw) * 3 + c]; k++ }
if (!k) continue
const d = s / k - u[g * 3 + c]
u[g * 3 + c] += omega * d
if (d > maxD) maxD = d; else if (-d > maxD) maxD = -d
}
}
if (maxD < tol) break
}
}
// Harmonic membrane: unknown cells (flag 2) take the Laplace interpolation of the known ones (flag 1);
// flag 0 is outside the image. Solved coarse to fine: the half-resolution solve supplies the smooth part
// and a few sweeps per level fix the boundary layer, since Gauss-Seidel kills high-frequency error fast.
function solveMembrane(flag: Uint8Array, u: Float32Array, gw: number, gh: number) {
const unk = unknowns(flag)
if (!unk.length) return
if (Math.max(gw, gh) <= 40) {
const sum = [0, 0, 0]; let n = 0
for (let g = 0; g < flag.length; g++) if (flag[g] === 1) { n++; for (let c = 0; c < 3; c++) sum[c] += u[g * 3 + c] }
for (let j = 0; j < unk.length; j++) for (let c = 0; c < 3; c++) u[unk[j] * 3 + c] = n ? sum[c] / n : 0
const m = Math.max(gw, gh)
sweeps(flag, u, gw, unk, 600, Math.min(1.95, 2 / (1 + Math.sin(Math.PI / (m + 1)))), 0.01)
return
}
// Coarse grid keeps a one-cell border so no unknown sits on its edge.
const cw = ((gw + 1) >> 1) + 2, ch = ((gh + 1) >> 1) + 2
const cf = new Uint8Array(cw * ch), cu = new Float32Array(cw * ch * 3), cnt = new Uint16Array(cw * ch)
for (let y = 0; y < gh; y++) for (let x = 0; x < gw; x++) {
const g = y * gw + x, f = flag[g]; if (!f) continue
const c = ((y >> 1) + 1) * cw + (x >> 1) + 1
if (f === 1) { cf[c] = 1; cnt[c]++; for (let k = 0; k < 3; k++) cu[c * 3 + k] += u[g * 3 + k] }
else if (!cf[c]) cf[c] = 2
}
for (let c = 0; c < cf.length; c++) if (cf[c] === 1) for (let k = 0; k < 3; k++) cu[c * 3 + k] /= cnt[c]
solveMembrane(cf, cu, cw, ch)
for (let j = 0; j < unk.length; j++) {
const g = unk[j], c = (((g / gw | 0) >> 1) + 1) * cw + ((g % gw) >> 1) + 1
for (let k = 0; k < 3; k++) u[g * 3 + k] = cu[c * 3 + k]
}
sweeps(flag, u, gw, unk, 20, 1.25, 0.01)
}
// Fill one piece: copy the best-matching patch over the mask, then add a harmonic membrane interpolated
// from (destination minus source) on the border so lighting matches at the seam. A near-flat ring (a sky,
// a gradient) or a mask too big to shift anywhere skips the copy: the membrane alone is a smooth fill,
// where a shifted copy would bend the gradient. Returns the ring's texture measure.
function healPiece(work: ImageData, pts: Pt[], brush: number): number {
const { width: W, height: H, data } = work
const ring = ringOf(brush)
const box = bboxOf(pts, brush / 2 + ring + 1, W, H)
const { x: bx, y: by, w: bw, h: bh } = box
const mask = coverage(pts, brush, box), dil = coverage(pts, brush + 2 * ring, box)
const mi: number[] = [], ri: number[] = []
for (let i = 0; i < bw * bh; i++) { if (mask[i]) mi.push(i); else if (dil[i] > 127) ri.push(i) }
if (!mi.length) return 0
const maskIdx = Int32Array.from(mi), ringIdx = Int32Array.from(ri)
// Texture: mean difference between horizontal neighbours across the ring.
let tex = 0, tn = 0
for (let k = 0; k < ringIdx.length; k += 7) {
const i = ringIdx[k]; if (i % bw === bw - 1) continue
const o = ((by + (i / bw | 0)) * W + bx + i % bw) * 4
tex += Math.abs(data[o] - data[o + 4]) + Math.abs(data[o + 1] - data[o + 5]) + Math.abs(data[o + 2] - data[o + 6]); tn++
}
tex = tn ? tex / (3 * tn) : 0
const off = tex > 0.8 && ringIdx.length ? bestOffset(data, W, H, box, mask, maskIdx, ringIdx) : null
const [dx, dy] = off ?? [0, 0]
// Padded grid: flag 0 = outside image, 1 = known (value = dst - src), 2 = unknown (mask).
const gw = bw + 2, gh = bh + 2
const flag = new Uint8Array(gw * gh), u = new Float32Array(gw * gh * 3)
for (let gy = 0; gy < gh; gy++) for (let gx = 0; gx < gw; gx++) {
const x = bx + gx - 1, y = by + gy - 1, g = gy * gw + gx
if (x < 0 || y < 0 || x >= W || y >= H) continue
if (gx > 0 && gy > 0 && gx <= bw && gy <= bh && mask[(gy - 1) * bw + gx - 1]) { flag[g] = 2; continue }
flag[g] = 1
const o = (y * W + x) * 4, q = (clamp(y + dy, 0, H - 1) * W + clamp(x + dx, 0, W - 1)) * 4
for (let c = 0; c < 3; c++) u[g * 3 + c] = data[o + c] - (off ? data[q + c] : 0)
}
solveMembrane(flag, u, gw, gh)
const shift = (dy * W + dx) * 4
for (let k = 0; k < maskIdx.length; k++) {
const i = maskIdx[k], lx = i % bw, ly = (i / bw) | 0
const o = ((by + ly) * W + bx + lx) * 4, g = (ly + 1) * gw + lx + 1, a = mask[i] / 255
for (let c = 0; c < 3; c++) {
const v = clamp((off ? data[o + shift + c] : 0) + u[g * 3 + c], 0, 255)
data[o + c] = data[o + c] * (1 - a) + v * a
}
}
return tex
}
// A stroke heals in pieces about two brush-widths long, each with its own source, so a long stroke can
// follow a background that changes along it.
// ---- Translucent overlays ----
// A constant-colour overlay at constant opacity leaves I = c + k*B (c = alpha*W, k = 1 - alpha): the picture is
// still in the pixels, attenuated. Fit (c, k) to the edge pairs inside the stroke, label the overlay's
// pixels, and invert the blend.
interface Overlay { c: number[]; k: number; alpha: number; W: number[]; n: number }
let fitLog = "" // why the last overlay fit was accepted or rejected, for the heal log
let debugLabels = false // a stroke message with debug: true paints the overlay label map instead of unblending
function fitOverlay(data: Uint8ClampedArray, IW: number, box: Box, mask: Uint8Array): { ov: Overlay; P: Int32Array; Q: Int32Array; inl: Uint8Array } | null {
const { x: bx, y: by, w: bw, h: bh } = box
// Candidate pairs: pixels two apart (past an anti-aliased edge) inside the stroke that differ
// noticeably, in both orders since either could be the overlay side.
const pp: number[] = [], qq: number[] = []
for (let ly = 0; ly < bh; ly++) for (let lx = 0; lx < bw; lx++) {
const i = ly * bw + lx; if (!mask[i]) continue
const o = ((by + ly) * IW + bx + lx) * 4
for (let d = 0; d < 2; d++) {
const jx = lx + (d ? 0 : 2), jy = ly + (d ? 2 : 0)
if (jx >= bw || jy >= bh || !mask[jy * bw + jx]) continue
const q = ((by + jy) * IW + bx + jx) * 4
if (Math.max(Math.abs(data[o] - data[q]), Math.abs(data[o + 1] - data[q + 1]), Math.abs(data[o + 2] - data[q + 2])) < 20) continue
pp.push(o, q); qq.push(q, o)
}
}
const stride = Math.max(1, Math.ceil(pp.length / 40000))
const n = Math.floor(pp.length / stride)
fitLog = `pairs ${pp.length / 2}`
if (n < 400) { fitLog += " too few"; return null }
const P = new Int32Array(n), Q = new Int32Array(n)
for (let t = 0; t < n; t++) { P[t] = pp[t * stride]; Q[t] = qq[t * stride] }
const resid = (c: number[], k: number, t: number) => Math.max(Math.abs(data[Q[t]] - c[0] - k * data[P[t]]), Math.abs(data[Q[t] + 1] - c[1] - k * data[P[t] + 1]), Math.abs(data[Q[t] + 2] - c[2] - k * data[P[t] + 2]))
const valid = (c: number[], k: number) => k >= 0.15 && k <= 0.92 && c.every(v => v > -6 && v < 261 && v / (1 - k) > -10 && v / (1 - k) < 265)
// RANSAC on two pairs: their difference cancels c and gives k.
let seed = 12345
const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff
const sub = Math.max(1, Math.floor(n / 4000))
let bc: number[] | null = null, bk = 0, bestCnt = 0
for (let it = 0; it < 500; it++) {
const i = Math.floor(rnd() * n), j = Math.floor(rnd() * n)
let pd = 0, qd = 0
for (let ch = 0; ch < 3; ch++) { const dp = data[P[i] + ch] - data[P[j] + ch]; pd += dp * dp; qd += dp * (data[Q[i] + ch] - data[Q[j] + ch]) }
if (pd < 900) continue
const k = qd / pd, c = [0, 1, 2].map(ch => data[Q[i] + ch] - k * data[P[i] + ch])
if (!valid(c, k)) continue
let cnt = 0
for (let t = 0; t < n; t += sub) if (resid(c, k, t) <= 10) cnt++
if (cnt > bestCnt) { bestCnt = cnt; bc = c; bk = k }
}
if (!bc) { fitLog += " no model"; return null }
// Refit on the inliers: shared slope, per-channel intercepts. The slope is the symmetric (geometric-mean)
// regression, since a pair's two pixels differ in texture as well as tint, and plain least squares reads
// that as noise in the regressor and shrinks k.
const inl = new Uint8Array(n)
let cnt = 0
for (let r = 0; r < 3; r++) {
const Sp = [0, 0, 0], Sq = [0, 0, 0], Spp = [0, 0, 0], Sqq = [0, 0, 0]
cnt = 0
for (let t = 0; t < n; t++) {
inl[t] = resid(bc, bk, t) <= 10 ? 1 : 0
if (!inl[t]) continue
cnt++
for (let ch = 0; ch < 3; ch++) { const p = data[P[t] + ch], q = data[Q[t] + ch]; Sp[ch] += p; Sq[ch] += q; Spp[ch] += p * p; Sqq[ch] += q * q }
}
if (cnt < 3) { fitLog += " lost"; return null }
let vp = 0, vq = 0
for (let ch = 0; ch < 3; ch++) { vp += Spp[ch] - Sp[ch] * Sp[ch] / cnt; vq += Sqq[ch] - Sq[ch] * Sq[ch] / cnt }
if (vp <= 0) { fitLog += " flat"; return null }
const k = Math.sqrt(vq / vp), c = [0, 1, 2].map(ch => (Sq[ch] - k * Sp[ch]) / cnt)
if (!valid(c, k)) { fitLog += ` invalid k=${k.toFixed(2)} c=${c.map(v => v.toFixed(0))}`; return null }
bc = c; bk = k
}
fitLog += ` ransac ${bestCnt}/${Math.ceil(n / sub)} inliers ${cnt}/${n} k=${bk.toFixed(2)} c=${bc.map(v => v.toFixed(0))}`
if (cnt < 300 || cnt < 0.03 * n) { fitLog += " weak"; return null }
// The inliers must span varied underlying colours: a single two-tone edge fits any (c, k).
let s1 = 0, s2 = 0
for (let t = 0; t < n; t++) if (inl[t]) { const l = 0.2126 * data[P[t]] + 0.7152 * data[P[t] + 1] + 0.0722 * data[P[t] + 2]; s1 += l; s2 += l * l }
const std = Math.sqrt(Math.max(0, s2 / cnt - (s1 / cnt) ** 2))
fitLog += ` std ${std.toFixed(0)}`
if (std < 12) { fitLog += " narrow"; return null }
const alpha = 1 - bk
return { ov: { c: bc, k: bk, alpha, W: bc.map(v => clamp(v / alpha, 0, 255)), n: cnt }, P, Q, inl }
}
function removeOverlay(work: ImageData, box: Box, mask: Uint8Array): Overlay | null {
const { data, width: IW } = work
const fit = fitOverlay(data, IW, box, mask)
if (!fit) return null
let ov = fit.ov
const { P, Q, inl } = fit
const { x: bx, y: by, w: bw, h: bh } = box
const N = bw * bh
const local = (o: number) => { const px = (o >> 2) % IW - bx, py = ((o >> 2) / IW | 0) - by; return py * bw + px }
const at = (i: number) => ((by + (i / bw | 0)) * IW + bx + i % bw) * 4
const nb4 = (i: number, d: number) => { const lx = i % bw, ly = (i / bw) | 0; return d === 0 ? (lx > 0 ? i - 1 : -1) : d === 1 ? (lx < bw - 1 ? i + 1 : -1) : d === 2 ? (ly > 0 ? i - bw : -1) : (ly < bh - 1 ? i + bw : -1) }
const inGamut = (i: number) => { const o = at(i); for (let ch = 0; ch < 3; ch++) { const v = (data[o + ch] - ov.c[ch]) / ov.k; if (v < -8 || v > 263) return false } return true }
// Each inlier pair votes: far pixel overlay, near pixel clean.
const vote = new Int16Array(N)
for (let t = 0; t < P.length; t++) if (inl[t]) { vote[local(Q[t])]++; vote[local(P[t])]-- }
const seeds = new Uint8Array(N)
for (let i = 0; i < N; i++) if (vote[i]) seeds[i] = vote[i] > 0 ? 2 : 1
// Regions: the stroke cut along every edge, overlay or natural (a step of 14+ between 4-neighbours).
// A region has one label, and the relation between two touching regions is read off their whole shared
// border at once, so a few missed pixels along an edge cannot open a gap for a flood to leak through.
const T = 14
const wall = new Uint8Array(N)
for (let i = 0; i < N; i++) {
if (!mask[i]) continue
const o = at(i)
for (let d = 0; d < 4 && !wall[i]; d++) {
const j = nb4(i, d); if (j < 0 || !mask[j]) continue
const oj = at(j)
if (Math.abs(data[o] - data[oj]) >= T || Math.abs(data[o + 1] - data[oj + 1]) >= T || Math.abs(data[o + 2] - data[oj + 2]) >= T) wall[i] = 1
}
}
const reg = new Int32Array(N).fill(-1)
let R = 0
const st: number[] = []
for (let s = 0; s < N; s++) {
if (!mask[s] || wall[s] || reg[s] >= 0) continue
reg[s] = R; st.push(s)
while (st.length) { const i = st.pop()!; for (let d = 0; d < 4; d++) { const j = nb4(i, d); if (j >= 0 && mask[j] && !wall[j] && reg[j] < 0) { reg[j] = R; st.push(j) } } }
R++
}
if (!R) return null
// Per-region evidence, signed toward overlay: the stroke's rim is clean, so is anything that unblends
// out of gamut, and the pair votes count too.
const ev = new Float64Array(R)
for (let i = 0; i < N; i++) {
const r = reg[i]; if (r < 0) continue
const lx = i % bw, ly = (i / bw) | 0
if (lx === 0 || ly === 0 || lx === bw - 1 || ly === bh - 1 || !mask[i - 1] || !mask[i + 1] || !mask[i - bw] || !mask[i + bw]) ev[r] -= 4
if (!inGamut(i)) ev[r] -= 4
ev[r] += vote[i]
}
// Border statistics from pixel pairs facing each other across a wall (walls are 2 to 4 px thick, since
// both sides of a step qualify): mean residual of "b is overlay relative to a", of the reverse, and of
// "no step at all".
interface Border { a: number; b: number; n: number; ab: number; ba: number; same: number }
const borders = new Map<number, Border>()
const bp: number[] = [] // the facing pairs themselves, pure pixels on both sides
const bv: number[] = [] // per pair: the two sides' colours, each extrapolated to the wall's midpoint
for (let i = 0; i < N; i++) {
const ra = reg[i]; if (ra < 0) continue
const lx = i % bw, ly = (i / bw) | 0
for (let d = 0; d < 2; d++) {
let j = -1
for (let s = 2; s <= 5; s++) {
const cand = d === 0 ? (lx + s < bw ? i + s : -1) : (ly + s < bh ? i + s * bw : -1)
if (cand < 0 || !mask[cand]) break
if (reg[cand] >= 0) { if (reg[cand] !== ra && s > 1 && reg[cand - (d === 0 ? 1 : bw)] < 0) j = cand; break }
}
if (j < 0) continue
const rb = reg[j]
// Both sides are extrapolated linearly to the wall's midpoint, so a texture slope across the gap
// cancels instead of shifting the fit (which the unblend then turns into a uniform offset).
const step = d === 0 ? 1 : bw, half = (j - i) / step / 2
const i2 = i - step, j2 = j + step
const hasI2 = (d === 0 ? lx > 0 : ly > 0) && mask[i2] && reg[i2] === ra
const hasJ2 = (d === 0 ? j % bw < bw - 1 : (j / bw | 0) < bh - 1) && mask[j2] && reg[j2] === rb
const oi = at(i), oj = at(j), oi2 = at(hasI2 ? i2 : i), oj2 = at(hasJ2 ? j2 : j)
const pv = [0, 1, 2].map(ch => data[oi + ch] + (data[oi + ch] - data[oi2 + ch]) * half)
const qv = [0, 1, 2].map(ch => data[oj + ch] + (data[oj + ch] - data[oj2 + ch]) * half)
bp.push(i, j); bv.push(pv[0], pv[1], pv[2], qv[0], qv[1], qv[2])
let ij = 0, ji = 0, same = 0
for (let ch = 0; ch < 3; ch++) {
ij = Math.max(ij, Math.abs(qv[ch] - ov.c[ch] - ov.k * pv[ch]))
ji = Math.max(ji, Math.abs(pv[ch] - ov.c[ch] - ov.k * qv[ch]))
same = Math.max(same, Math.abs(qv[ch] - pv[ch]))
}
const lo = Math.min(ra, rb), hi = Math.max(ra, rb), key = lo * R + hi
let s = borders.get(key)
if (!s) { s = { a: lo, b: hi, n: 0, ab: 0, ba: 0, same: 0 }; borders.set(key, s) }
s.n++; s.same += Math.min(same, 60)
if (ra === lo) { s.ab += Math.min(ij, 60); s.ba += Math.min(ji, 60) } else { s.ab += Math.min(ji, 60); s.ba += Math.min(ij, 60) }
}
}
// Each border is a same/different constraint weighted by length and margin; a natural edge, which
// fits neither blend direction, means the same label on both sides. Union-find with parity takes the
// constraints strongest first, and an oriented border also says which side is the overlay.
const rels: { a: number; b: number; rel: number; w: number }[] = []
for (const s of borders.values()) {
const mab = s.ab / s.n, mba = s.ba / s.n, msame = s.same / s.n, best = Math.min(mab, mba, msame)
if (best === msame || best > 14) { rels.push({ a: s.a, b: s.b, rel: 0, w: s.n * Math.max(1, Math.min(mab, mba) - msame) }); continue }
const w = s.n * (Math.min(msame, Math.max(mab, mba)) - best)
rels.push({ a: s.a, b: s.b, rel: 1, w })
if (mab < mba) { ev[s.b] += w; ev[s.a] -= w } else { ev[s.a] += w; ev[s.b] -= w }
}
rels.sort((x, y) => y.w - x.w)
const parent = new Int32Array(R), parity = new Uint8Array(R), size = new Int32Array(R).fill(1)
for (let r = 0; r < R; r++) parent[r] = r
const rootOf = (x: number) => { while (parent[x] !== x) x = parent[x]; return x }
const parOf = (x: number) => { let p = 0; while (parent[x] !== x) { p ^= parity[x]; x = parent[x] } return p }
for (const { a, b, rel } of rels) {
let ra = rootOf(a), rb = rootOf(b)
if (ra === rb) continue
const pa = parOf(a), pb = parOf(b)
if (size[ra] < size[rb]) { const t = ra; ra = rb; rb = t }
parent[rb] = ra; parity[rb] = pa ^ pb ^ rel; size[ra] += size[rb]
}
const compEv = new Float64Array(R)
for (let r = 0; r < R; r++) compEv[rootOf(r)] += parOf(r) ? -ev[r] : ev[r]
const label = new Uint8Array(N)
let overCount = 0
for (let i = 0; i < N; i++) {
const r = reg[i]; if (r < 0) continue
const over = (compEv[rootOf(r)] > 0) !== (parOf(r) === 1)
label[i] = over ? 2 : 1
if (over) overCount++
}
fitLog += ` regions ${R} borders ${borders.size} overlay px ${overCount}`
if (!overCount) return null
// Wall pixels take the label of their nearest labelled neighbour.
let fill: number[] = []
for (let i = 0; i < N; i++) if (mask[i] && label[i]) fill.push(i)
while (fill.length) {
const next: number[] = []
for (const i of fill) for (let d = 0; d < 4; d++) { const j = nb4(i, d); if (j >= 0 && mask[j] && !label[j]) { label[j] = label[i]; next.push(j) } }
fill = next
}
// Specks flip: a component under 40 px is noise, whichever label it carries.
const seen = new Uint8Array(N), stack: number[] = [], comp: number[] = []
for (let s = 0; s < N; s++) {
if (!mask[s] || seen[s]) continue
const l = label[s]; comp.length = 0; stack.push(s); seen[s] = 1
while (stack.length) {
const i = stack.pop()!; comp.push(i)
for (let d = 0; d < 4; d++) { const j = nb4(i, d); if (j >= 0 && mask[j] && !seen[j] && label[j] === l) { seen[j] = 1; stack.push(j) } }
}
if (comp.length < 40) for (const i of comp) label[i] = 3 - l
}
// The facing pairs whose regions ended up with different labels: far more than the seeds, so refit
// (c, k) on them with the same symmetric regression, then judge the result on them too.
const pairs: number[] = [], pc: number[] = [], po: number[] = [] // (i, j) clean/overlay, and their midpoint colours
for (let t = 0; t < bp.length; t += 2) {
const i = bp[t], j = bp[t + 1], v = t * 3
if (label[i] === 1 && label[j] === 2) { pairs.push(i, j); pc.push(bv[v], bv[v + 1], bv[v + 2]); po.push(bv[v + 3], bv[v + 4], bv[v + 5]) }
else if (label[i] === 2 && label[j] === 1) { pairs.push(j, i); pc.push(bv[v + 3], bv[v + 4], bv[v + 5]); po.push(bv[v], bv[v + 1], bv[v + 2]) }
}
for (let r = 0; r < 2 && pairs.length >= 100; r++) {
const Sp = [0, 0, 0], Sq = [0, 0, 0], Spp = [0, 0, 0], Sqq = [0, 0, 0]; let m = 0
for (let t = 0; t < pc.length; t += 3) {
let res = 0
for (let ch = 0; ch < 3; ch++) res = Math.max(res, Math.abs(po[t + ch] - ov.c[ch] - ov.k * pc[t + ch]))
if (res > 14) continue
m++
for (let ch = 0; ch < 3; ch++) { const p = pc[t + ch], q = po[t + ch]; Sp[ch] += p; Sq[ch] += q; Spp[ch] += p * p; Sqq[ch] += q * q }
}
if (m < 50) break
let vp = 0, vq = 0
for (let ch = 0; ch < 3; ch++) { vp += Spp[ch] - Sp[ch] * Sp[ch] / m; vq += Sqq[ch] - Sq[ch] * Sq[ch] / m }
if (vp <= 0) break
const k = Math.sqrt(vq / vp), c = [0, 1, 2].map(ch => (Sq[ch] - k * Sp[ch]) / m)
if (k < 0.1 || k > 0.95) break
ov = { c, k, alpha: 1 - k, W: c.map(v => clamp(v / (1 - k), 0, 255)), n: m }
}
fitLog += ` refit k=${ov.k.toFixed(2)} on ${ov.n}`
if (debugLabels) {
// Overlay red, clean green, seeds solid.
for (let i = 0; i < N; i++) {
if (!mask[i]) continue
const o = at(i), l = label[i]
const t = [l === 2 ? 255 : 0, l === 1 ? 255 : 0, 0], a = 0.45
for (let ch = 0; ch < 3; ch++) data[o + ch] = data[o + ch] * (1 - a) + t[ch] * a
}
for (let i = 0; i < N; i++) if (seeds[i]) { const o = at(i); data[o] = seeds[i] === 2 ? 255 : 0; data[o + 1] = seeds[i] === 1 ? 255 : 0; data[o + 2] = 0 }
fitLog += " debug"
return ov
}
// Invert the blend on overlay pixels, except one that would leave the gamut: it cannot be overlay,
// whatever the fill labelled it (a clean sliver too thin to be a region, between a stem and a
// diagonal, went black without this). Then every wall pixel between the two labels (the anti-aliased rim
// and the pure pixels flanking it) keeps its own value if it already matches the recovered colours of
// the pure pixels around it, and otherwise takes their median (a mean straddles a fold edge crossing the
// rim and leaves a tick): a 1-2 px band of interpolation is invisible, while unblending a partially
// covered pixel with any estimated opacity leaves a dotted outline.
const { c, k } = ov
const B = new Float32Array(N * 3)
for (let i = 0; i < N; i++) {
if (!mask[i]) continue
const o = at(i)
const over = label[i] === 2 && inGamut(i)
for (let ch = 0; ch < 3; ch++) B[i * 3 + ch] = over ? (data[o + ch] - c[ch]) / k : data[o + ch]
}
const out = new Float32Array(B)
for (let i = 0; i < N; i++) {
if (!mask[i] || !wall[i]) continue
const lx = i % bw, ly = (i / bw) | 0
let has1 = false, has2 = false
const vals: number[][] = [[], [], []]
for (let rad = 1; rad <= 2 && !vals[0].length; rad++)
for (let y = Math.max(0, ly - rad); y <= Math.min(bh - 1, ly + rad); y++) for (let x = Math.max(0, lx - rad); x <= Math.min(bw - 1, lx + rad); x++) {
const j = y * bw + x; if (!mask[j]) continue
if (label[j] === 1) has1 = true; else if (label[j] === 2) has2 = true
if (!wall[j]) for (let ch = 0; ch < 3; ch++) vals[ch].push(B[j * 3 + ch])
}
if (!((has1 && has2) || (label[i] === 2 && !inGamut(i))) || !vals[0].length) continue
const o = at(i)
const m = vals.map(v => { v.sort((p, q) => p - q); return v[v.length >> 1] })
let diff = 0
for (let ch = 0; ch < 3; ch++) diff = Math.max(diff, Math.abs(data[o + ch] - m[ch]))
for (let ch = 0; ch < 3; ch++) out[i * 3 + ch] = diff <= 10 ? data[o + ch] : m[ch]
}
// Accept only if the overlay's edge vanishes: the step between the facing pairs' midpoint colours must
// collapse once the overlay side is unblended. An opaque mark's anti-aliased rim fits the model too,
// but its core stays a step, and this catches it.
let eb = 0, ea = 0, en = 0
for (let t = 0; t < pc.length; t += 3) {
let b = 0, a = 0
for (let ch = 0; ch < 3; ch++) { b = Math.max(b, Math.abs(po[t + ch] - pc[t + ch])); a = Math.max(a, Math.abs((po[t + ch] - c[ch]) / k - pc[t + ch])) }
eb += b; ea += a; en++
}
fitLog += ` edge ${en ? (eb / en).toFixed(1) : "-"}->${en ? (ea / en).toFixed(1) : "-"} over ${en}`
if (!en || ea > 0.3 * eb || ea / en > 12) { fitLog += " edge stays"; return null }
fitLog += " ok"
for (let i = 0; i < N; i++) {
if (!mask[i]) continue
const o = at(i)
for (let ch = 0; ch < 3; ch++) data[o + ch] = clamp(out[i * 3 + ch], 0, 255)
}
return ov
}
interface Info { tex: number[]; overlay: Overlay | null }
let lastInfo: Info | null = null // what the latest heal did, for the self-test's report
// A stroke first tries the translucent-overlay model over its whole area; failing that it heals in pieces
// about two brush-widths long, each with its own source, so a long stroke can follow a changing background.
function healStroke(work: ImageData, pts: Pt[], brush: number): [Patch, Info] {
const box = bboxOf(pts, brush / 2 + ringOf(brush) + 1, work.width, work.height)
const before = copyBox(work, box)
fitLog = ""
const overlay = removeOverlay(work, box, coverage(pts, brush, box))
if (overlay) return [{ box, before }, { tex: [], overlay }]
const step = Math.max(1, brush / 3)
const rs = resample(pts, step)
const per = Math.max(2, Math.round(Math.max(2 * brush, 16) / step))
const tex: number[] = []
for (let i = 0; ; i += per - 1) {
tex.push(healPiece(work, rs.slice(i, i + per), brush))
if (i + per >= rs.length) break
}
return [{ box, before }, { tex, overlay: null }]
}
const tint = (w: number[]) => Math.min(...w) > 200 ? "white" : Math.max(...w) < 60 ? "black" : Math.max(...w) - Math.min(...w) < 25 ? "grey" : `rgb(${w.map(Math.round).join(",")})`
// Synthetic scene for the headless self-test: a striped texture over a colour gradient with a dark spot, a
// scratch, and a translucent banner, plus the exact pixels each mark changed, so the heal can be scored
// against the clean ground truth.
function makeTestScene() {
const W = 420, H = 300
const c = document.createElement("canvas"); c.width = W; c.height = H
const x = c.getContext("2d", { willReadFrequently: true })!
const im = x.createImageData(W, H)
let seed = 7
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff - 0.5 }
for (let y = 0; y < H; y++) for (let xx = 0; xx < W; xx++) {
const o = (y * W + xx) * 4, s = Math.sin(y * 0.31) * 26 + Math.sin((xx + 2 * y) * 0.09) * 9
im.data[o] = 158 + s + (xx / W - 0.5) * 90 + rnd() * 6; im.data[o + 1] = 122 + s * 0.8 + rnd() * 6; im.data[o + 2] = 88 + s * 0.6 + (y / H - 0.5) * 70 + rnd() * 6; im.data[o + 3] = 255
}
x.putImageData(im, 0, 0)
const clean = x.getImageData(0, 0, W, H)
const changed = (prev: ImageData) => {
const cur = x.getImageData(0, 0, W, H), idx: number[] = []
for (let o = 0; o < cur.data.length; o += 4) if (cur.data[o] !== prev.data[o] || cur.data[o + 1] !== prev.data[o + 1] || cur.data[o + 2] !== prev.data[o + 2]) idx.push(o)
return { idx, snap: cur }
}
x.fillStyle = "#3a2418"; x.beginPath(); x.ellipse(210, 150, 12, 9, 0.4, 0, Math.PI * 2); x.fill()
const spot = changed(clean)
x.strokeStyle = "#111"; x.lineWidth = 3; x.beginPath(); x.moveTo(60, 60); x.lineTo(160, 120); x.stroke()
const scratch = changed(spot.snap)
x.fillStyle = "rgba(255,255,255,0.5)"; x.fillRect(60, 215, 200, 50)
const wm = changed(scratch.snap)
return { canvas: c, clean, marks: [spot.idx, scratch.idx, wm.idx] }
}
function App() {
const [img, setImg] = useState<{ w: number; h: number; name: string } | null>(null)
const [brush, setBrush] = useState(24)
const [fixes, setFixes] = useState(0)
const [busy, setBusy] = useState(false)
const [stroke, setStroke] = useState<Pt[]>([])
const [cursor, setCursor] = useState<Pt | null>(null)
const [peek, setPeek] = useState(false) // brush circle shown on the image while its size is being changed
const [hover, setHover] = useState(false)
const [toast, setToast] = useState("")
const [zoom, setZoom] = useState(1)
const [stage, setStage] = useState({ w: 0, h: 320 })
const canvasRef = useRef<HTMLCanvasElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
const workRef = useRef<ImageData | null>(null)
const origRef = useRef<ImageData | null>(null)
const undoRef = useRef<Patch[]>([])
const strokeRef = useRef<Pt[]>([])
const drawing = useRef(false)
const imgRef = useRef(img); imgRef.current = img
const zoomRef = useRef(zoom); zoomRef.current = zoom
const pendingScroll = useRef<{ fx: number; fy: number; px: number; py: number } | null>(null)
const flash = (m: string) => { setToast(m); window.setTimeout(() => setToast(""), 1700) }
const peekTimer = useRef(0)
const resize = (f: (b: number) => number) => {
setBrush(b => clamp(f(b), BRUSH_MIN, BRUSH_MAX))
setPeek(true); window.clearTimeout(peekTimer.current); peekTimer.current = window.setTimeout(() => setPeek(false), 800)
}
const fit = img && stage.w ? Math.min(1, stage.w / img.w, stage.h / img.h) : 1
const scale = fit * zoom
const dispW = img ? img.w * scale : 0, dispH = img ? img.h * scale : 0
const brushImg = brush / scale
// Whatever the canvas shows becomes the image to heal, and the original the compare button shows.
const adopt = useCallback((name: string) => {
const cv = canvasRef.current!, w = cv.width, h = cv.height
const work = cv.getContext("2d", { willReadFrequently: true })!.getImageData(0, 0, w, h)
workRef.current = work
origRef.current = new ImageData(new Uint8ClampedArray(work.data), w, h)
undoRef.current = []
setFixes(0); setZoom(1); setImg({ w, h, name })
}, [])
const loadSource = useCallback((src: CanvasImageSource, w: number, h: number, name: string) => {
const cv = canvasRef.current!; cv.width = w; cv.height = h
cv.getContext("2d", { willReadFrequently: true })!.drawImage(src, 0, 0)
adopt(name)
}, [adopt])
const loadUrl = useCallback((url: string, name: string) => new Promise<void>((res, rej) => {
const im = new Image()
im.onload = () => { loadSource(im, im.naturalWidth, im.naturalHeight, name); res() }
im.onerror = () => rej(new Error("image failed to load"))
im.src = url
}), [loadSource])
const loadFile = useCallback((file?: File | null) => {
if (!file || !file.type.startsWith("image/")) return
const url = URL.createObjectURL(file)
loadUrl(url, file.name).finally(() => URL.revokeObjectURL(url))
}, [loadUrl])
// The heal runs a beat later so the "Healing…" hint can paint first. A timeout, not requestAnimationFrame,
// which never fires while the page is hidden.
const heal = useCallback((pts: Pt[], b: number, debug = false) => new Promise<Patch>((res, rej) => {
const work = workRef.current!, cv = canvasRef.current!
setBusy(true)
debugLabels = debug
setTimeout(() => {
const t0 = performance.now()
try {
const [patch, info] = healStroke(work, pts, b)
lastInfo = info
cv.getContext("2d")!.putImageData(work, 0, 0, patch.box.x, patch.box.y, patch.box.w, patch.box.h)
undoRef.current.push(patch)
if (undoRef.current.length > 40) undoRef.current.shift()
setFixes(undoRef.current.length)
const ov = info.overlay
if (ov) flash(`Unblended a translucent overlay: ${Math.round(ov.alpha * 100)}% ${tint(ov.W)}`)
tb.log(`[heal] ${pts.length} pts, brush ${Math.round(b)}px, box ${patch.box.w}x${patch.box.h}, ${Math.round(performance.now() - t0)}ms (${document.visibilityState}), ` +
(ov ? `overlay ${Math.round(ov.alpha * 100)}% ${tint(ov.W)} (${ov.n} pairs)` : `tex ${info.tex.map(t => t.toFixed(1)).join("/")}`) + ` | fit: ${fitLog}`)
res(patch)
} catch (e: any) {
tb.log(`[heal error] ${e?.stack || e}`); flash("Heal failed"); rej(e)
} finally { setBusy(false) }
}, 20)
}), [])
const undo = useCallback(() => {
const p = undoRef.current.pop(), work = workRef.current, cv = canvasRef.current
if (!p || !work || !cv) return
pasteBox(work, p.box, p.before)
cv.getContext("2d")!.putImageData(work, 0, 0, p.box.x, p.box.y, p.box.w, p.box.h)
setFixes(undoRef.current.length)
}, [])
const showOriginal = (on: boolean) => {
const cv = canvasRef.current, w = workRef.current, o = origRef.current
if (cv && w && o) cv.getContext("2d")!.putImageData(on ? o : w, 0, 0)
}
useEffect(() => {
const el = stageRef.current; if (!el) return
const measure = () => setStage({ w: el.clientWidth, h: Math.max(320, window.innerHeight - 140) })
const ro = new ResizeObserver(measure); ro.observe(el)
window.addEventListener("resize", measure); measure()
return () => { ro.disconnect(); window.removeEventListener("resize", measure) }
}, [])
// Ctrl/⌘+wheel (and trackpad pinch) zooms about the pointer; the scroll fix-up lands after layout.
useEffect(() => {
const el = stageRef.current; if (!el) return
const onWheel = (e: WheelEvent) => {
if (!(e.ctrlKey || e.metaKey) || !imgRef.current) return
e.preventDefault()
const cv = canvasRef.current!, r = cv.getBoundingClientRect(), sr = el.getBoundingClientRect()
const z1 = clamp(zoomRef.current * Math.exp(-e.deltaY * 0.0015), 1, 8)
if (z1 === zoomRef.current) return
pendingScroll.current = { fx: (e.clientX - r.left) / r.width, fy: (e.clientY - r.top) / r.height, px: e.clientX - sr.left - el.clientLeft, py: e.clientY - sr.top - el.clientTop }
setZoom(z1)
}
el.addEventListener("wheel", onWheel, { passive: false })
return () => el.removeEventListener("wheel", onWheel)
}, [])
useLayoutEffect(() => {
const p = pendingScroll.current, el = stageRef.current, cv = canvasRef.current
if (!p || !el || !cv) return
pendingScroll.current = null
const fr = cv.parentElement as HTMLElement
el.scrollLeft = fr.offsetLeft + p.fx * cv.offsetWidth - p.px
el.scrollTop = fr.offsetTop + p.fy * cv.offsetHeight - p.py
}, [zoom])
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items; if (!items) return
for (const it of items) if (it.type.startsWith("image/")) { loadFile(it.getAsFile()); break }
}
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { e.preventDefault(); undo() }
else if (e.key === "[") resize(b => b - 4)
else if (e.key === "]") resize(b => b + 4)
}
window.addEventListener("paste", onPaste); window.addEventListener("keydown", onKey)
return () => { window.removeEventListener("paste", onPaste); window.removeEventListener("keydown", onKey) }
}, [loadFile, undo])
// Headless hooks: `selftest` scores a synthetic heal; {load, watermark?, spots?} loads a data URL,
// optionally stamping a translucent watermark and dark spots [[x,y,r],…] first; {stroke: [[x,y],…], brush}
// heals in image px.
useEffect(() => tb.onMessage(async (m: any) => {
if (m === "selftest") {
const { canvas, clean, marks } = makeTestScene()
loadSource(canvas, canvas.width, canvas.height, "selftest.png")
const work = workRef.current!
const err = (idx: number[]) => { let s = 0; for (const o of idx) for (let c = 0; c < 3; c++) s += Math.abs(work.data[o + c] - clean.data[o + c]); return +(s / (idx.length * 3)).toFixed(1) }
const before = marks.map(err)
const t0 = performance.now()
const paths: string[] = []
for (const [pts, b] of [[[{ x: 210, y: 150 }], 44], [[{ x: 60, y: 60 }, { x: 160, y: 120 }], 12], [[{ x: 60, y: 240 }, { x: 260, y: 240 }], 80]] as [Pt[], number][]) {
await heal(pts, b); paths.push(lastInfo?.overlay ? "overlay" : "fill")
}
const after = marks.map(err)
return { before, after, paths, ms: Math.round(performance.now() - t0), verdict: after.every((a, i) => a < before[i] / 4) && paths[2] === "overlay" ? "pass" : "fail" }
}
if (m && typeof m === "object") {
if (m.load) {
await loadUrl(m.load, "loaded.png")
if (m.watermark || m.spots) {
const cv = canvasRef.current!, ctx = cv.getContext("2d")!
ctx.save(); ctx.font = `bold ${Math.round(cv.width / 12)}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"
ctx.fillStyle = "rgba(255,255,255,0.55)"; if (m.watermark) ctx.fillText(m.watermark, cv.width / 2, cv.height / 2)
ctx.fillStyle = "#3a2418"
for (const [x, y, r] of m.spots ?? []) { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill() }
ctx.restore()
adopt("loaded.png")
}
return { w: cv().width, h: cv().height }
}
if (m.stroke) { const p = await heal(m.stroke.map(([x, y]: number[]) => ({ x, y })), m.brush ?? 24, !!m.debug); return p.box }
}
function cv() { return canvasRef.current! }
}), [loadSource, adopt, loadUrl, heal])
const toImg = (e: React.PointerEvent) => {
const cv = canvasRef.current!, r = cv.getBoundingClientRect()
return { x: (e.clientX - r.left) * cv.width / r.width, y: (e.clientY - r.top) * cv.height / r.height }
}
const onDown = (e: React.PointerEvent) => {
if (!img || busy) return
e.currentTarget.setPointerCapture(e.pointerId)
drawing.current = true
const p = toImg(e); strokeRef.current = [p]; setStroke([p])
}
const onMove = (e: React.PointerEvent) => {
const p = toImg(e)
setCursor(e.pointerType === "touch" ? null : p)
if (!drawing.current) return
const s = strokeRef.current, l = s[s.length - 1]
if (Math.hypot(p.x - l.x, p.y - l.y) < 0.5) return
s.push(p); setStroke([...s])
}
const onUp = () => {
if (!drawing.current) return
drawing.current = false
const pts = strokeRef.current; strokeRef.current = []; setStroke([])
heal(pts, brushImg)
}
const download = () => {
const cv = canvasRef.current; if (!cv || !img) return
const a = document.createElement("a"); a.download = img.name.replace(/\.[^.]+$/, "") + "-healed.png"; a.href = cv.toDataURL("image/png"); a.click()
}
const copy = () => {
const cv = canvasRef.current; if (!cv) return
cv.toBlob(async b => {
if (!b) return
try { await navigator.clipboard.write([new ClipboardItem({ "image/png": b })]); flash("Copied to clipboard") }
catch { flash("Copy blocked — use Download") }
}, "image/png")
}
const points = stroke.length === 1 ? `${stroke[0].x},${stroke[0].y} ${stroke[0].x + 0.01},${stroke[0].y}` : stroke.map(p => `${p.x},${p.y}`).join(" ")
// Where the size preview sits: under the pointer if it is over the image, else the middle of what is in view.
const ring = cursor ?? (() => {
const cv = canvasRef.current, st = stageRef.current
if (!cv || !st || !img) return null
const r = cv.getBoundingClientRect(), s = st.getBoundingClientRect()
const cx = (Math.max(r.left, s.left) + Math.min(r.right, s.right)) / 2, cy = (Math.max(r.top, s.top) + Math.min(r.bottom, s.bottom)) / 2
return { x: (cx - r.left) * cv.width / r.width, y: (cy - r.top) * cv.height / r.height }
})()
const showRing = !busy && (cursor || peek) && ring
return (
<div className="wrap">
<div
ref={stageRef}
className={"stage" + (hover ? " hover" : "")}
style={{ maxHeight: stage.h + 2 }}
onDragOver={e => { e.preventDefault(); setHover(true) }}
onDragLeave={() => setHover(false)}
onDrop={e => { e.preventDefault(); setHover(false); loadFile(e.dataTransfer.files?.[0]) }}
>
<div className="frame" hidden={!img} style={{ width: dispW, height: dispH }}>
<canvas
ref={canvasRef}
className={"art" + (busy ? " busy" : "")}
aria-label="image"
title="Ctrl+scroll to zoom"
style={{ width: dispW, height: dispH }}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onPointerLeave={() => setCursor(null)}
/>
{img && (
<svg className="ov" viewBox={`0 0 ${img.w} ${img.h}`} style={{ width: dispW, height: dispH }} aria-hidden="true">
{stroke.length > 0 && <polyline points={points} fill="none" stroke="#14b8a6" strokeOpacity={0.5} strokeWidth={brushImg} strokeLinecap="round" strokeLinejoin="round" />}
{showRing && <>
<circle cx={ring.x} cy={ring.y} r={brushImg / 2} fill="none" stroke="rgba(0,0,0,0.6)" strokeWidth={3} vectorEffect="non-scaling-stroke" />
<circle cx={ring.x} cy={ring.y} r={brushImg / 2} fill="none" stroke="#fff" strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
</>}
</svg>
)}
</div>
{!img && (
<label className="drop">
<input type="file" accept="image/*" hidden onChange={e => loadFile(e.target.files?.[0])} />
<div className="dropIcon">⌖</div>
<div className="dropMain">Drop an image, paste with <kbd>Ctrl</kbd>+<kbd>V</kbd>, or click</div>
<div className="dropSub">Then paint over a blemish or watermark and let go</div>
</label>
)}
</div>
<footer className="bar">
<input ref={fileRef} type="file" accept="image/*" hidden onChange={e => { loadFile(e.target.files?.[0]); e.target.value = "" }} />
<button className="iconbtn" title="Open an image" aria-label="Open an image" onClick={() => fileRef.current?.click()}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
</button>
<label className="brushctl" title="Brush size ([ and ] keys)">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="12" cy="12" r="8" /></svg>
<input type="range" min={BRUSH_MIN} max={BRUSH_MAX} value={brush} onChange={e => { const v = +e.target.value; resize(() => v) }} aria-label="Brush size" disabled={!img} />
</label>
<span className="spin" hidden={!busy} aria-label="Healing" />
<span className="grow" />
<button className="iconbtn" title="Undo (Ctrl+Z)" aria-label="Undo" disabled={!fixes} onClick={undo}>↶</button>
<button className="iconbtn" title="Hold to see the original" aria-label="Show original" disabled={!fixes}
onPointerDown={() => showOriginal(true)} onPointerUp={() => showOriginal(false)} onPointerLeave={() => showOriginal(false)}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z" /><circle cx="12" cy="12" r="3" />
</svg>
</button>
<button className="iconbtn" title="Copy image" aria-label="Copy image" disabled={!img} onClick={copy}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="8.5" cy="9.5" r="1.5" /><path d="M21 16l-5-5-6 6" />
</svg>
</button>
<button className="iconbtn accent" title="Download PNG" aria-label="Download PNG" disabled={!img} onClick={download}>⤓</button>
</footer>
<div className={"toast" + (toast ? " show" : "")}>{toast}</div>
</div>
)
}
createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**
```css
* { box-sizing: border-box; }
.wrap {
max-width: 960px;
margin: 0 auto;
padding: 20px 16px 16px;
font: 14px system-ui, -apple-system, sans-serif;
color: CanvasText;
display: grid;
gap: 12px;
}
.bar { display: flex; align-items: center; gap: 8px; }
.grow { flex: 1 1 auto; }
.spin {
width: 16px; height: 16px; border-radius: 50%;
border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
border-top-color: #14b8a6;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.brushctl { display: inline-flex; align-items: center; gap: 10px; height: 32px; padding: 0 4px; }
.brushctl svg { flex: 0 0 auto; opacity: 0.8; }
.brushctl input { width: 140px; accent-color: #14b8a6; cursor: pointer; margin: 0; }
.brushctl input:disabled { opacity: 0.4; cursor: default; }
.iconbtn {
flex: 0 0 auto;
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 16px; line-height: 1;
border-radius: 8px; cursor: pointer;
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
background: transparent; color: inherit;
touch-action: none;
}
.iconbtn:hover:not(:disabled) { background: color-mix(in srgb, currentColor 8%, transparent); }
.iconbtn.accent { border-color: #14b8a6; color: #14b8a6; }
.iconbtn:disabled { opacity: 0.4; cursor: default; }
.stage {
position: relative;
border: 1px dashed color-mix(in srgb, currentColor 28%, transparent);
border-radius: 12px;
background: color-mix(in srgb, currentColor 4%, transparent);
min-height: 320px;
display: flex;
overflow: auto;
}
.stage.hover { border-color: #14b8a6; background: color-mix(in srgb, #14b8a6 12%, transparent); }
/* margin:auto centres a frame that fits and lets one that overflows scroll from its top-left */
.frame { position: relative; margin: auto; flex: 0 0 auto; }
.art { display: block; touch-action: none; cursor: none; }
.art.busy { cursor: progress; }
.ov { position: absolute; inset: 0; pointer-events: none; }
.drop { display: grid; gap: 6px; place-items: center; align-content: center; text-align: center; padding: 48px 24px; cursor: pointer; width: 100%; }
.dropIcon { font-size: 38px; opacity: 0.55; line-height: 1; }
.dropMain { font-size: 15px; font-weight: 500; }
.dropSub { font-size: 13px; opacity: 0.6; }
kbd {
font: inherit; padding: 1px 6px; border-radius: 5px;
border: 1px solid color-mix(in srgb, currentColor 35%, transparent);
background: color-mix(in srgb, currentColor 8%, transparent);
}
.toast {
position: fixed; left: 50%; bottom: 22px; transform: translate(-50%, 12px);
background: CanvasText; color: Canvas; padding: 8px 16px; border-radius: 999px; font-size: 13px;
opacity: 0; pointer-events: none; transition: opacity 0.18s, transform 0.18s;
}
.toast.show { opacity: 0.92; transform: translate(-50%, 0); }
```
**index.html**
```html
<div id="root"></div>
```
**config.json**
```json
{
"description": "Remove watermarks, spots, and scratches from a photo: paint over the mark and it disappears. Runs in your browser. Nothing uploaded.",
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
}
}
```