const sharp = require('sharp'); // Source anchors read off the image, mapped by ROLE to MyBazaar tokens. // Anti-aliased pixels are blended between the two nearest anchors so edges stay smooth // instead of snapping — a straight nearest-colour swap would leave jagged rims. const MAP = [ { from: [255,255,255], to: [234,246,243] }, // ground -> peacock.10 { from: [228,228,228], to: [207,229,224] }, // shoe / shade -> peacock.20 { from: [197,207,214], to: [215,236,232] }, // jacket -> pine.20 { from: [175,185,197], to: [186,214,208] }, // jacket shade -> between pine.20/peacock.20 { from: [177,186,192], to: [200,224,218] }, // jacket mid { from: [219,39,33], to: [1,98,88] }, // trouser shade -> pine.80 { from: [255,65,51], to: [1,121,111] }, // trousers -> pine.70 (the primary) { from: [178,139,103], to: [215,236,232] }, // skin -> pine.20 (no skin tones) { from: [153,118,89], to: [186,214,208] }, // skin shade { from: [16,16,48], to: [17,56,51] }, // hair -> peacock.90 ]; (async () => { const src = '/tmp/moodboard/recolor-src.png', out = '/tmp/moodboard/recolor-out.png'; const { data, info } = await sharp(src).raw().toBuffer({ resolveWithObject: true }); const { width: W, height: H, channels: C } = info; const d2 = (p, q) => (p[0]-q[0])**2 + (p[1]-q[1])**2 + (p[2]-q[2])**2; for (let i = 0; i < data.length; i += C) { const px = [data[i], data[i+1], data[i+2]]; let a = 0, b = 1, da = Infinity, db = Infinity; for (let k = 0; k < MAP.length; k++) { const dist = d2(px, MAP[k].from); if (dist < da) { db = da; b = a; da = dist; a = k; } else if (dist < db) { db = dist; b = k; } } if (da === 0) { [data[i], data[i+1], data[i+2]] = MAP[a].to; continue; } // weight by inverse distance to the two closest anchors const wa = 1 / Math.sqrt(da), wb = 1 / Math.sqrt(db), s = wa + wb; for (let c = 0; c < 3; c++) data[i+c] = Math.round((MAP[a].to[c]*wa + MAP[b].to[c]*wb) / s); } await sharp(data, { raw: { width: W, height: H, channels: C } }).png().toFile(out); console.log('->', out); })();