mirror of
https://github.com/zanwei/design-dna.git
synced 2026-09-14 19:59:56 +08:00
269 lines
8.9 KiB
JavaScript
269 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Deterministic color measurement for the design-dna skill.
|
|
//
|
|
// Instead of perceiving colors from a screenshot (which drifts toward familiar
|
|
// palette defaults), this measures them: k-means clustering over the actual
|
|
// pixels, perceptual (CIE ΔE) merging of near-duplicate clusters, and coverage
|
|
// fractions. The output is meant to be merged into `design_system.color` of
|
|
// a Design DNA JSON — exact hexes with evidence, not guesses.
|
|
//
|
|
// Usage:
|
|
// node scripts/measure-colors.mjs <screenshot.(png|jpg|webp)> [--k 8]
|
|
//
|
|
// Output (stdout): JSON
|
|
// {
|
|
// "source": { "file", "width", "height" },
|
|
// "measurement": { "k", "sampling" },
|
|
// "palette": [ { "hex", "coverage", "role" } ... ],
|
|
// "measured": true
|
|
// }
|
|
|
|
import sharp from "sharp";
|
|
import { basename, extname } from "node:path";
|
|
import { deltaE, hex, hsv } from "./color-math.mjs";
|
|
|
|
const args = process.argv.slice(2);
|
|
const kIdx = args.indexOf("--k");
|
|
const file = args.find(
|
|
(argument, index) =>
|
|
!argument.startsWith("--") && (kIdx < 0 || index !== kIdx + 1)
|
|
);
|
|
if (!file) {
|
|
console.error("usage: node scripts/measure-colors.mjs <image> [--k 8]");
|
|
process.exit(1);
|
|
}
|
|
const requestedK = kIdx >= 0 ? Number(args[kIdx + 1]) : 8;
|
|
const K = Number.isFinite(requestedK)
|
|
? Math.max(2, Math.min(16, Math.trunc(requestedK)))
|
|
: 8;
|
|
const MAX_SAMPLED_PIXELS = 160_000;
|
|
|
|
// ---------- k-means ----------
|
|
|
|
function kmeans(pixels, k, iters = 24) {
|
|
// deterministic farthest-point init: start from the darkest pixel, then
|
|
// repeatedly add the pixel farthest from its nearest existing center, so
|
|
// small but distinct color regions get their own cluster
|
|
const sorted = [...pixels].sort(
|
|
(a, b) => a[0] * 3 + a[1] * 6 + a[2] - (b[0] * 3 + b[1] * 6 + b[2])
|
|
);
|
|
const centers = [[...sorted[0]]];
|
|
const nearest = new Array(pixels.length).fill(Infinity);
|
|
while (centers.length < k) {
|
|
const last = centers[centers.length - 1];
|
|
let far = 0, fd = -1;
|
|
for (let p = 0; p < pixels.length; p++) {
|
|
const dx = pixels[p][0] - last[0];
|
|
const dy = pixels[p][1] - last[1];
|
|
const dz = pixels[p][2] - last[2];
|
|
const d = dx * dx + dy * dy + dz * dz;
|
|
if (d < nearest[p]) nearest[p] = d;
|
|
if (nearest[p] > fd) (fd = nearest[p]), (far = p);
|
|
}
|
|
if (fd <= 0) break; // fewer distinct colors than k
|
|
centers.push([...pixels[far]]);
|
|
}
|
|
k = centers.length;
|
|
const assign = new Array(pixels.length).fill(0);
|
|
for (let it = 0; it < iters; it++) {
|
|
let moved = false;
|
|
for (let p = 0; p < pixels.length; p++) {
|
|
let best = 0, bd = Infinity;
|
|
for (let c = 0; c < k; c++) {
|
|
const dx = pixels[p][0] - centers[c][0];
|
|
const dy = pixels[p][1] - centers[c][1];
|
|
const dz = pixels[p][2] - centers[c][2];
|
|
const d = dx * dx + dy * dy + dz * dz;
|
|
if (d < bd) (bd = d), (best = c);
|
|
}
|
|
if (assign[p] !== best) (assign[p] = best), (moved = true);
|
|
}
|
|
const sums = Array.from({ length: k }, () => [0, 0, 0, 0]);
|
|
for (let p = 0; p < pixels.length; p++) {
|
|
const s = sums[assign[p]];
|
|
s[0] += pixels[p][0]; s[1] += pixels[p][1]; s[2] += pixels[p][2]; s[3]++;
|
|
}
|
|
for (let c = 0; c < k; c++) {
|
|
if (sums[c][3] > 0) {
|
|
centers[c] = [sums[c][0] / sums[c][3], sums[c][1] / sums[c][3], sums[c][2] / sums[c][3]];
|
|
}
|
|
}
|
|
if (!moved) break;
|
|
}
|
|
const counts = new Array(k).fill(0);
|
|
for (const a of assign) counts[a]++;
|
|
return centers
|
|
.map((center, i) => ({ center, share: counts[i] / pixels.length }))
|
|
.filter((c) => c.share > 0)
|
|
.sort((a, b) => b.share - a.share);
|
|
}
|
|
|
|
// merge perceptually-identical clusters (anti-aliasing / jpeg noise)
|
|
function mergeSimilar(clusters, maxDE = 2.5) {
|
|
const merged = [];
|
|
for (const c of clusters) {
|
|
const near = merged.find((m) => deltaE(m.center, c.center) <= maxDE);
|
|
if (near) {
|
|
const total = near.share + c.share;
|
|
near.center = near.center.map(
|
|
(v, i) => (v * near.share + c.center[i] * c.share) / total
|
|
);
|
|
near.share = total;
|
|
} else {
|
|
merged.push({ center: [...c.center], share: c.share });
|
|
}
|
|
}
|
|
return merged.sort((a, b) => b.share - a.share);
|
|
}
|
|
|
|
// ---------- role assignment ----------
|
|
|
|
function assignRoles(clusters) {
|
|
const entries = clusters.map(({ center, share }) => {
|
|
const [h, s, l] = hsv(center);
|
|
return { center, share, h, s, l, role: "unassigned" };
|
|
});
|
|
const taken = new Set();
|
|
// background: largest coverage
|
|
entries[0].role = "background";
|
|
taken.add(0);
|
|
const bgL = entries[0].l;
|
|
// text: strongest lightness contrast vs background with meaningful coverage
|
|
let text = -1, bestC = 0;
|
|
entries.forEach((e, i) => {
|
|
if (taken.has(i)) return;
|
|
const c = Math.abs(e.l - bgL);
|
|
if (e.share >= 0.005 && c > bestC) (bestC = c), (text = i);
|
|
});
|
|
if (text >= 0 && bestC > 0.25) {
|
|
entries[text].role = "text";
|
|
taken.add(text);
|
|
}
|
|
// Accent: prefer colors that are both saturated and perceptually distinct
|
|
// from the background. Coverage only discounts tiny clusters; once a color
|
|
// reaches 2%, a large tinted surface must not outrank a smaller bright CTA.
|
|
const accents = entries
|
|
.map((e, i) => ({ e, i }))
|
|
.filter(
|
|
({ e, i }) =>
|
|
!taken.has(i) && e.s >= 0.25 && e.share >= 0.002 && e.l >= 0.08 && e.l <= 0.92
|
|
)
|
|
.map(({ e, i }) => ({
|
|
e,
|
|
i,
|
|
score:
|
|
e.s *
|
|
deltaE(e.center, entries[0].center) *
|
|
Math.sqrt(Math.min(e.share, 0.02) / 0.02),
|
|
}))
|
|
.sort((a, b) => b.score - a.score);
|
|
if (accents.length > 0) {
|
|
entries[accents[0].i].role = "accent";
|
|
taken.add(accents[0].i);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
// ---------- deterministic bounded sampling ----------
|
|
|
|
// A single nearest-neighbour resize always samples at the same phase. That can
|
|
// erase periodic detail entirely (for example, every other column in 1 px
|
|
// stripes). Instead, divide the full row-major pixel sequence into equal
|
|
// strata and choose one exact source pixel from each stratum with a fixed hash.
|
|
// The sample count is bounded, no interpolated colors are introduced, and the
|
|
// varying offset breaks the fixed phase that causes periodic aliasing.
|
|
function mix32(value) {
|
|
let x = (value + 0x9e3779b9) >>> 0;
|
|
x = Math.imul(x ^ (x >>> 16), 0x21f0aaad);
|
|
x = Math.imul(x ^ (x >>> 15), 0x735a2d97);
|
|
return (x ^ (x >>> 15)) >>> 0;
|
|
}
|
|
|
|
function sampleIndex(sample, totalPixels, sampleCount) {
|
|
const start = Math.floor((sample * totalPixels) / sampleCount);
|
|
const end = Math.floor(((sample + 1) * totalPixels) / sampleCount);
|
|
return start + (mix32(sample) % Math.max(1, end - start));
|
|
}
|
|
|
|
async function samplePixels(image, width, height) {
|
|
const totalPixels = width * height;
|
|
const sampleCount = Math.min(totalPixels, MAX_SAMPLED_PIXELS);
|
|
const pixels = [];
|
|
let sample = 0;
|
|
let nextIndex = sampleIndex(sample, totalPixels, sampleCount);
|
|
let pixelIndex = 0;
|
|
let carry = Buffer.alloc(0);
|
|
|
|
// Stream raw output so the JavaScript-side sample stays bounded even when
|
|
// the decoded source is much larger than the clustering budget.
|
|
for await (const chunk of image.raw()) {
|
|
const data = carry.length ? Buffer.concat([carry, chunk]) : chunk;
|
|
const usable = data.length - (data.length % 3);
|
|
const firstPixel = pixelIndex;
|
|
const nextChunkPixel = firstPixel + usable / 3;
|
|
while (sample < sampleCount && nextIndex < nextChunkPixel) {
|
|
const offset = (nextIndex - firstPixel) * 3;
|
|
pixels.push([data[offset], data[offset + 1], data[offset + 2]]);
|
|
sample++;
|
|
if (sample < sampleCount) {
|
|
nextIndex = sampleIndex(sample, totalPixels, sampleCount);
|
|
}
|
|
}
|
|
pixelIndex = nextChunkPixel;
|
|
carry = data.subarray(usable);
|
|
}
|
|
|
|
if (carry.length !== 0 || pixels.length !== sampleCount) {
|
|
throw new Error(
|
|
`expected ${sampleCount} RGB samples, received ${pixels.length}`
|
|
);
|
|
}
|
|
return pixels;
|
|
}
|
|
|
|
// ---------- main ----------
|
|
|
|
const img = sharp(file).flatten({ background: "#ffffff" }).toColourspace("srgb");
|
|
let width, height;
|
|
try {
|
|
({ width, height } = await img.metadata());
|
|
} catch (err) {
|
|
console.error(`error: cannot read ${file}: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
let pixels;
|
|
try {
|
|
pixels = await samplePixels(img, width, height);
|
|
} catch (err) {
|
|
console.error(`error: cannot sample ${file}: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// JPEG compression spreads flat colors into wider noise bands than PNG/WebP
|
|
const isJpeg = [".jpg", ".jpeg"].includes(extname(file).toLowerCase());
|
|
const palette = assignRoles(mergeSimilar(kmeans(pixels, K), isJpeg ? 5 : 2.5));
|
|
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
source: { file: basename(file), width, height },
|
|
measurement: {
|
|
k: K,
|
|
sampling: {
|
|
method: "deterministic_stratified",
|
|
max_pixels: MAX_SAMPLED_PIXELS,
|
|
sampled_pixels: pixels.length,
|
|
},
|
|
},
|
|
palette: palette.map((p) => ({
|
|
hex: hex(p.center),
|
|
coverage: Number(p.share.toFixed(4)),
|
|
role: p.role,
|
|
})),
|
|
measured: true,
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|