// Convert the opaque pale drop shadow to translucent ink, and sweep its fringe. // usage: yarn node < convert-shadow.cjs (or pass ) — edits in place. const sharp = require('sharp'); const fs = require('node:fs'); const D = (process.argv[2] || '/home/ubuntu/generated-images/character-moodboard/poses-crisp') + '/'; const INK = [17, 56, 51]; // peacock.90 const MAX_A = 118; // darkest point of the shadow, out of 255 (async () => { for (const f of fs.readdirSync(D).filter(f => f.endsWith('.png')).sort()) { const { data, info } = await sharp(D + f).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const { width: W, height: H, channels: C } = info; const A = p => data[p * C + 3]; const lum = p => (data[p*C]*0.299 + data[p*C+1]*0.587 + data[p*C+2]*0.114); // Candidate shadow pixels: opaque, pale, and in the shadow's tonal band — // pale enough to be background-ish but darker than the near-white props. const pale = new Uint8Array(W * H); for (let p = 0; p < W * H; p++) { if (A(p) < 250) continue; const l = lum(p); if (l >= 175 && l <= 243) pale[p] = 1; // upper bound must reach the shadow's faint outer fringe, // or that fringe survives as an opaque pale ring } const seen = new Uint8Array(W * H); const shadowPx = []; for (let p0 = 0; p0 < W * H; p0++) { if (!pale[p0] || seen[p0]) continue; const st = [p0]; seen[p0] = 1; const comp = []; let touchesVoid = false, sumY = 0, minL = 255; while (st.length) { const p = st.pop(); comp.push(p); const x = p % W, y = (p / W) | 0; sumY += y; minL = Math.min(minL, lum(p)); 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) { touchesVoid = true; continue; } const np = ny*W+nx; if (A(np) === 0) touchesVoid = true; // sits against the cut-out void if (pale[np] && !seen[np]) { seen[np]=1; st.push(np); } } } const cy = sumY / comp.length; // the shadow is the only pale mass that is unenclosed AND low in the frame if (touchesVoid && cy > H * 0.70 && comp.length > 300) shadowPx.push({ comp, minL }); } let converted = 0; const isShadow = new Uint8Array(W * H); for (const { comp, minL } of shadowPx) { const bgL = 246, range = Math.max(8, bgL - minL); for (const p of comp) { const i = p * C; const depth = Math.max(0, Math.min(1, (bgL - lum(p)) / range)); data[i] = INK[0]; data[i+1] = INK[1]; data[i+2] = INK[2]; data[i+3] = Math.round(depth * MAX_A); isShadow[p] = 1; converted++; } } // Sweep up any opaque pale straggler still clinging to the converted ellipse. // These are what read as a bright outline ring around the shadow. if (converted) for (let pass = 0; pass < 8; pass++) { const add = []; for (let y = 1; y < H - 1; y++) for (let x = 1; x < W - 1; x++) { const p = y * W + x, i = p * C; // Gate on alpha > 20, not > 200: the ring left by the cut's feathered edge sits at // alpha ~150 and was invisible to both this sweep and the component detector above. if (isShadow[p] || data[i + 3] < 20) continue; if (lum(p) < 120) continue; // stops at the outline (<110) and the teal body (~85) let nextToShadow = false; for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) if (isShadow[(y+dy)*W + (x+dx)]) nextToShadow = true; if (nextToShadow) add.push(p); } for (const p of add) { const i = p * C; const soft = data[i + 3] / 255; // keep the feather it already had data[i] = INK[0]; data[i+1] = INK[1]; data[i+2] = INK[2]; data[i+3] = Math.round(MAX_A * 0.55 * soft); isShadow[p] = 1; converted++; } if (!add.length) break; } // Final sweep: any pixel still on the outer boundary that is lighter than the outline // and has no outline near it is leftover shadow fringe, stranded as an island the // dilation could not reach. Nothing legitimate lives there — real light fills (belly, // boxes, card) are bordered by their own dark outline. let stray = 0; for (let y = 1; y < H - 1; y++) for (let x = 1; x < W - 1; x++) { const i = (y*W+x)*C; if (data[i+3] < 10) continue; if (lum(y*W+x) <= 120) continue; let onEdge = false; for (const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]) if (data[((y+dy)*W+(x+dx))*C+3] < 10) onEdge = true; if (!onEdge) continue; let nearOutline = false; for (let dy=-2; dy<=2 && !nearOutline; dy++) for (let dx=-2; dx<=2; dx++) { const j = ((y+dy)*W+(x+dx))*C; if (data[j+3] > 128 && lum((y+dy)*W+(x+dx)) < 110) { nearOutline = true; break; } } if (!nearOutline) { data[i+3] = 0; stray++; } } if (stray) console.log(` ${stray} stray fringe px cleared`); await sharp(data, { raw: { width: W, height: H, channels: C } }).png({ compressionLevel: 9 }).toFile(D + f + '.s'); fs.renameSync(D + f + '.s', D + f); console.log(` ${f.padEnd(20)} ${shadowPx.length} shadow region(s), ${converted} px -> translucent ink`); } })();