// Put back small bright accent props that refine-edges.cjs erodes away. // usage: yarn node - < restore-accents.cjs — edits crispDir in place. // // refine-edges step 0 strips pale pixels that touch the cut-out void AND sit within 2px of a // dark outline, three passes deep. Its comment states the assumption: "light fills are never // adjacent to the void — their own dark outline borders it." True for the belly patch and for // boxes, false for a small free-floating prop: a gold star is only a few px of fill inside its // own outline, so every one of its pixels satisfies both tests and the whole prop erodes. // // Luminance is why this is selective rather than general: gold #F0C64B measures 197 and is // caught, magenta #C42B60 measures 95 and falls below the 120 threshold untouched — which is // why the "?" on 23-shrug survives and the star on 22-cheer does not. // // This runs AFTER refine-edges and only restores components that it removed outright, so it // cannot alter any pose that did not lose one. const sharp = require('sharp'); const fs = require('node:fs'); const CUT = process.argv[2] + '/', CRISP = process.argv[3] + '/'; const MIN_PX = 24, MAX_PX = 4000; // bigger than noise, smaller than the character (async () => { for (const f of fs.readdirSync(CRISP).filter(f => f.endsWith('.png')).sort()) { if (!fs.existsSync(CUT + f)) continue; const a = await sharp(CUT + f).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const b = await sharp(CRISP + f).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); if (a.info.width !== b.info.width || a.info.height !== b.info.height) continue; const { width: W, height: H, channels: C } = a.info; const seen = new Uint8Array(W * H); let restored = 0, comps = 0; for (let p0 = 0; p0 < W * H; p0++) { if (seen[p0] || a.data[p0 * C + 3] < 40) continue; const st = [p0]; seen[p0] = 1; const comp = []; while (st.length) { const p = st.pop(); comp.push(p); const x = p % W, y = (p / W) | 0; for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]) { const nx = x + dx, ny = y + dy; if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; const np = ny * W + nx; if (a.data[np * C + 3] >= 40 && !seen[np]) { seen[np] = 1; st.push(np); } } } if (comp.length < MIN_PX || comp.length > MAX_PX) continue; // Only a component the refine pass wiped out entirely — a survivor is left alone. const kept = comp.filter(p => b.data[p * C + 3] > 8).length; if (kept > comp.length * 0.1) continue; for (const p of comp) for (let c = 0; c < C; c++) b.data[p * C + c] = a.data[p * C + c]; restored += comp.length; comps++; } if (restored) { await sharp(b.data, { raw: { width: W, height: H, channels: C } }).png().toFile(CRISP + f + '.t'); fs.renameSync(CRISP + f + '.t', CRISP + f); console.log(` ${f.padEnd(16)} restored ${comps} accent component(s), ${restored} px`); } } })();