// Strip the pale anti-alias halo, steepen the alpha ramp, recolour the rim from neighbours. // usage: yarn node < refine-edges.cjs (or pass ) // NO median/blur/sharpen filter — it thickens the outline at this size. const sharp = require('sharp'); const fs = require('node:fs'); const SRC = (process.argv[2] || '/home/ubuntu/generated-images/character-moodboard/poses-cut') + '/'; const OUT = (process.argv[3] || '/home/ubuntu/generated-images/character-moodboard/poses-crisp') + '/'; fs.mkdirSync(OUT, { recursive: true }); const LO = 70, HI = 205; // alpha remap band: below LO -> 0, above HI -> 255 (async () => { for (const f of fs.readdirSync(SRC).filter(f => f.endsWith('.png')).sort()) { const { data, info } = await sharp(SRC + f).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const { width: W, height: H, channels: C } = info; const lum = j => data[j]*0.299 + data[j+1]*0.587 + data[j+2]*0.114; // 0. Strip the pale anti-alias ring that hugs the outline. // These pixels sit between the mint background and the dark outline and land around // rgb(200,226,220) — almost exactly the shadow's tone, so NO colour tolerance can // separate them. They are removed structurally instead: pale + touching the cut-out // void + a dark outline pixel within 2px. The shadow has no outline near it, so it // is untouched. for (let pass = 0; pass < 3; pass++) { const doomed = []; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const i = (y*W+x)*C; if (data[i+3] === 0) continue; // Threshold set from the artwork, not by chasing the fringe down step by step: // the outline measures < 110 and the teal body ~85, so ANY boundary pixel above // ~120 is a blend with the mint ground, not real content. Light fills (belly, // boxes, card) are never adjacent to the void — their own dark outline borders it. if (lum(i) < 120) continue; let void_ = false; for (const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { const nx=x+dx, ny=y+dy; if (nx<0||ny<0||nx>=W||ny>=H) { void_ = true; continue; } if (data[(ny*W+nx)*C+3] === 0) void_ = true; } if (!void_) continue; let nearOutline = false; for (let dy=-2; dy<=2 && !nearOutline; dy++) for (let dx=-2; dx<=2; dx++) { const nx=x+dx, ny=y+dy; if (nx<0||ny<0||nx>=W||ny>=H) continue; const j=(ny*W+nx)*C; if (data[j+3] > 128 && lum(j) < 110) { nearOutline = true; break; } } if (nearOutline) doomed.push(i); } for (const i of doomed) data[i+3] = 0; if (pass === 0) var ringPx = doomed.length; else ringPx += doomed.length; } // 1. steepen the alpha ramp so the rim reads as an edge, not a fade for (let p = 0, i = 0; p < W * H; p++, i += C) { const a = data[i + 3]; if (a === 0 || a === 255) continue; data[i + 3] = a <= LO ? 0 : a >= HI ? 255 : Math.round(((a - LO) / (HI - LO)) * 255); } // 2. Colour-decontaminate the rim by PROPAGATING the nearest opaque colour inward. // The algebraic un-blend fg = (observed - bg(1-a)) / a looks correct but divides // by a small alpha, which amplifies sensor noise and clamps to 255 — that is what // produced the scattered white pixels along the outline. Copying a real neighbouring // colour cannot overshoot. const src = Buffer.from(data); let fixed = 0; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const i = (y * W + x) * C; const a = src[i + 3]; if (a === 0 || a === 255) continue; let r = 0, g = 0, b = 0, n = 0; for (let rad = 1; rad <= 3 && n === 0; rad++) { for (let dy = -rad; dy <= rad; dy++) for (let dx = -rad; dx <= rad; dx++) { if (Math.max(Math.abs(dx), Math.abs(dy)) !== rad) continue; const nx = x + dx, ny = y + dy; if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; const j = (ny * W + nx) * C; if (src[j + 3] !== 255) continue; r += src[j]; g += src[j + 1]; b += src[j + 2]; n++; } } if (n) { data[i] = (r / n) | 0; data[i + 1] = (g / n) | 0; data[i + 2] = (b / n) | 0; fixed++; } } // NO median / blur / sharpen filter. A median(3) flattens the generator grain but // visibly thickens and softens the outline — the art is only ~370px tall. await sharp(data, { raw: { width: W, height: H, channels: C } }) .png({ compressionLevel: 9 }).toFile(OUT + f); console.log(` ${f.padEnd(20)} halo ring ${ringPx} px removed, ${fixed} rim px recoloured`); } })();