// 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`);
}
})();