fix: harden deterministic color verification

This commit is contained in:
zanwei.guo
2026-08-28 09:57:25 +08:00
parent 18f3725250
commit d313bd5f20
12 changed files with 496 additions and 63 deletions
+6 -2
View File
@@ -92,8 +92,10 @@ El JSON de DNA es el artefacto central. Una vez extraído, puede **versionarse**
La percepción de color de los LLM tiende a desviarse hacia valores predeterminados de paletas conocidas: un rosa de marca como `#ff90e8` puede «verse» como `#ec4899` (ΔE ≈ 29). Dos scripts opcionales permiten medir las fases de análisis y generación:
Los siguientes comandos manuales están pensados para un clon local de este repositorio. Ejecútalos desde la raíz del clon:
```bash
cd scripts && npm install && cd ..
npm install --prefix ./scripts
# Analizar: medir la paleta exacta de una captura de referencia
node scripts/measure-colors.mjs reference.png > measured-colors.json
@@ -102,7 +104,9 @@ node scripts/measure-colors.mjs reference.png > measured-colors.json
node scripts/verify.mjs implementation.png measured-colors.json
```
`measure-colors.mjs` ejecuta un agrupamiento k-means determinista sobre los píxeles reales (fusionando el ruido del antialiasing mediante ΔE perceptual) y genera colores hexadecimales exactos con porcentajes de cobertura y funciones de fondo/texto/acento. `verify.mjs` vuelve a medir el resultado generado e informa el ΔE por color y la desviación de cobertura con umbrales PASS/FAIL. Así, el agente puede autocorregirse sin depender de la evaluación visual del usuario. Cuando las referencias son imágenes, la habilidad indica a los agentes que usen ambos scripts automáticamente. No se necesitan claves de API.
Cuando se instala como habilidad de agente mediante la instalación rápida, el agente debe resolver estos scripts desde el directorio absoluto que contiene el `SKILL.md` cargado; el usuario no necesita un directorio `scripts/` en la raíz de su proyecto.
`measure-colors.mjs` ejecuta un agrupamiento k-means determinista sobre los píxeles reales (fusionando el ruido del antialiasing mediante ΔE perceptual) y genera colores hexadecimales exactos, cobertura como fracciones `0..1`, funciones de fondo/texto/acento y el `k` del agrupamiento. `verify.mjs` reutiliza el `k` registrado, vuelve a medir el resultado generado e informa el ΔE por color y la desviación de cobertura con umbrales PASS/FAIL. Así, el agente puede autocorregirse sin depender de la evaluación visual del usuario. Cuando las referencias son imágenes, la habilidad indica a los agentes que usen ambos scripts automáticamente. No se necesitan claves de API.
Misma referencia (el hero de bun.sh), mismo agente: reconstrucción percibida frente a reconstrucción medida.
+6 -2
View File
@@ -92,8 +92,10 @@ DNA JSON が中核のアーティファクトです。一度抽出すれば、**
LLM の色知覚は、よく使われるパレットの既定値に寄ることがあります。たとえばブランドカラーのピンク `#ff90e8``#ec4899` と「認識」される場合がありますΔE ≈ 29。次の二つの任意スクリプトを使うと、分析フェーズと生成フェーズを定量的に評価できます。
以下の手動コマンドは、このリポジトリをローカルにクローンした場合のものです。クローンのルートから実行してください:
```bash
cd scripts && npm install && cd ..
npm install --prefix ./scripts
# 分析:参考スクリーンショットから正確なパレットを計測
node scripts/measure-colors.mjs reference.png > measured-colors.json
@@ -102,7 +104,9 @@ node scripts/measure-colors.mjs reference.png > measured-colors.json
node scripts/verify.mjs implementation.png measured-colors.json
```
`measure-colors.mjs` は実際のピクセルに対して決定論的な k-means クラスタリングを行い(アンチエイリアスのノイズは知覚的 ΔE により統合)、正確な 16 進カラー、占有率、背景/テキスト/アクセントの役割を出力します。`verify.mjs` は生成結果を再計測し、色ごとの ΔE と占有率のずれを PASS/FAIL 判定付きで報告します。これにより、ユーザーの目視に頼らずエージェントが自己修正できます。参考が画像ファイルの場合、スキルは両方のスクリプトを自動的に使うようエージェントへ指示します。API キーは不要です
クイックインストールでエージェントスキルとしてインストールした場合、エージェントは読み込んだ `SKILL.md` がある絶対ディレクトリを基準にこれらのスクリプトを解決する必要があります。ユーザーのプロジェクトルートに `scripts/` ディレクトリがある必要はありません
`measure-colors.mjs` は実際のピクセルに対して決定論的な k-means クラスタリングを行い(アンチエイリアスのノイズは知覚的 ΔE により統合)、正確な 16 進カラー、`0..1` の比率で表した占有率、背景/テキスト/アクセントの役割、およびクラスタリングの `k` を出力します。`verify.mjs` は記録された `k` を再利用して生成結果を再計測し、色ごとの ΔE と占有率のずれを PASS/FAIL 判定付きで報告します。これにより、ユーザーの目視に頼らずエージェントが自己修正できます。参考が画像ファイルの場合、スキルは両方のスクリプトを自動的に使うようエージェントへ指示します。API キーは不要です。
同じ参考bun.sh のヒーロー)、同じエージェントで、知覚による再現と計測による再現を比較:
+6 -2
View File
@@ -92,8 +92,10 @@ DNA JSON이 핵심 산출물입니다. 한 번 추출하면 **버전 관리에
LLM의 색상 인식은 익숙한 팔레트 기본값 쪽으로 치우칠 수 있습니다. 예를 들어 브랜드 핑크 `#ff90e8``#ec4899`로 "인식"할 수 있습니다(ΔE ≈ 29). 다음 두 선택적 스크립트를 사용하면 분석 및 생성 단계를 정량적으로 측정할 수 있습니다.
아래 수동 명령은 이 저장소의 로컬 클론용입니다. 클론 루트에서 실행하세요:
```bash
cd scripts && npm install && cd ..
npm install --prefix ./scripts
# 분석: 참고 스크린샷에서 정확한 팔레트 측정
node scripts/measure-colors.mjs reference.png > measured-colors.json
@@ -102,7 +104,9 @@ node scripts/measure-colors.mjs reference.png > measured-colors.json
node scripts/verify.mjs implementation.png measured-colors.json
```
`measure-colors.mjs`는 실제 픽셀에 결정론적 k-means 클러스터링을 수행하고(앤티앨리어싱 노이즈는 지각적 ΔE로 병합), 정확한 16진수 색상, 점유율, 배경/텍스트/강조 역할을 출력합니다. `verify.mjs`는 생성 결과를 다시 측정해 색상별 ΔE와 점유율 편차를 PASS/FAIL 판정과 함께 보고합니다. 따라서 사용자의 육안 판단에 의존하지 않고 에이전트가 스스로 보정할 수 있습니다. 참고 자료가 이미지 파일이면 스킬은 에이전트가 두 스크립트를 자동으로 사용하도록 지시합니다. API 키는 필요하지 않습니다.
빠른 설치로 에이전트 스킬을 설치한 경우 에이전트는 로드한 `SKILL.md`가 있는 절대 디렉터리를 기준으로 이 스크립트 경로를 해석해야 합니다. 사용자 프로젝트 루트에 `scripts/` 디렉터리가 있을 필요는 없습니다.
`measure-colors.mjs`는 실제 픽셀에 결정론적 k-means 클러스터링을 수행하고(앤티앨리어싱 노이즈는 지각적 ΔE로 병합), 정확한 16진수 색상, `0..1` 비율로 표시한 점유율, 배경/텍스트/강조 역할, 클러스터링 `k`를 출력합니다. `verify.mjs`는 기록된 `k`를 재사용해 생성 결과를 다시 측정하고 색상별 ΔE와 점유율 편차를 PASS/FAIL 판정과 함께 보고합니다. 따라서 사용자의 육안 판단에 의존하지 않고 에이전트가 스스로 보정할 수 있습니다. 참고 자료가 이미지 파일이면 스킬은 에이전트가 두 스크립트를 자동으로 사용하도록 지시합니다. API 키는 필요하지 않습니다.
같은 참고 자료(bun.sh의 히어로), 같은 에이전트로 지각 기반 재구성과 측정 기반 재구성을 비교합니다.
+6 -2
View File
@@ -102,8 +102,10 @@ The DNA JSON is the key artifact. Once extracted, it can be **committed to versi
LLM color perception drifts toward familiar palette defaults — a brand pink like `#ff90e8` gets "seen" as `#ec4899` (ΔE ≈ 29). Two optional scripts make the Analyze and Generate phases measurable:
The manual commands below are for a local clone of this repository. Run them from the clone's root:
```bash
cd scripts && npm install && cd ..
npm install --prefix ./scripts
# Analyze: measure the exact palette from a reference screenshot
node scripts/measure-colors.mjs reference.png > measured-colors.json
@@ -112,7 +114,9 @@ node scripts/measure-colors.mjs reference.png > measured-colors.json
node scripts/verify.mjs implementation.png measured-colors.json
```
`measure-colors.mjs` runs deterministic k-means clustering over the actual pixels (with perceptual ΔE merging of anti-aliasing noise) and outputs exact hexes with coverage percentages and background/text/accent roles. `verify.mjs` re-measures the generated output and reports per-color ΔE and coverage drift with PASS/FAIL thresholds, giving the agent a self-correction loop instead of relying on the user's eye. The skill instructs agents to use both automatically when references are image files; no API keys required.
When installed as an agent skill via Quick Install, the agent must resolve these scripts from the absolute directory containing the loaded `SKILL.md`; users do not need a `scripts/` directory in their project root.
`measure-colors.mjs` runs deterministic k-means clustering over the actual pixels (with perceptual ΔE merging of anti-aliasing noise) and outputs exact hexes, coverage as `0..1` fractions, background/text/accent roles, and the clustering `k`. `verify.mjs` reuses the recorded `k`, re-measures the generated output, and reports per-color ΔE and coverage drift with PASS/FAIL thresholds, giving the agent a self-correction loop instead of relying on the user's eye. The skill instructs agents to use both automatically when references are image files; no API keys required.
Same reference (bun.sh's hero), same agent — perceived rebuild vs measured rebuild:
+6 -2
View File
@@ -92,8 +92,10 @@ DNA JSON 是核心产物。一旦提取完成,它可以**提交到版本控制
LLM 对颜色的感知容易向常见调色板的默认值偏移——例如品牌粉色 `#ff90e8` 可能被“看成” `#ec4899`ΔE ≈ 29。以下两个可选脚本可让分析与生成阶段获得可量化的结果
以下手工命令适用于本仓库的本地克隆,请在克隆目录的根目录中运行:
```bash
cd scripts && npm install && cd ..
npm install --prefix ./scripts
# 分析:从参考截图中测量精确调色板
node scripts/measure-colors.mjs reference.png > measured-colors.json
@@ -102,7 +104,9 @@ node scripts/measure-colors.mjs reference.png > measured-colors.json
node scripts/verify.mjs implementation.png measured-colors.json
```
`measure-colors.mjs` 对实际像素执行确定性 k-means 聚类(通过感知 ΔE 合并抗锯齿噪点),输出精确的十六进制颜色、覆盖率以及背景/文本/强调色角色。`verify.mjs` 会重新测量生成结果,报告每种颜色的 ΔE 和覆盖率偏差,并给出 PASS/FAIL 结果,让智能体能够自我校正,而不必依赖用户目测。参考素材为图片文件时,技能会指示智能体自动使用这两个脚本;无需 API 密钥
通过“快速安装”安装为智能体技能时,智能体应以已加载的 `SKILL.md` 所在绝对目录为基准解析这些脚本;用户无需在自己的项目根目录中准备 `scripts/` 目录
`measure-colors.mjs` 对实际像素执行确定性 k-means 聚类(通过感知 ΔE 合并抗锯齿噪点),输出精确的十六进制颜色、以 `0..1` 比例表示的覆盖率、背景/文本/强调色角色以及聚类参数 `k``verify.mjs` 会复用记录的 `k`,重新测量生成结果,报告每种颜色的 ΔE 和覆盖率偏差,并给出 PASS/FAIL 结果,让智能体能够自我校正,而不必依赖用户目测。参考素材为图片文件时,技能会指示智能体自动使用这两个脚本;无需 API 密钥。
同一份参考bun.sh 的首屏)、同一个智能体——感知重建与测量重建的对比:
+6 -2
View File
@@ -92,8 +92,10 @@ DNA JSON 是核心產物。一旦擷取完成,它可以**提交到版本控制
LLM 對顏色的感知容易偏向常見調色盤的預設值——例如品牌粉紅色 `#ff90e8` 可能被「看成」`#ec4899`ΔE ≈ 29。以下兩個選用腳本可讓分析與生成階段獲得可量化的結果
以下手動指令適用於本儲存庫的本機複製,請在複製目錄的根目錄中執行:
```bash
cd scripts && npm install && cd ..
npm install --prefix ./scripts
# 分析:從參考截圖測量精確調色盤
node scripts/measure-colors.mjs reference.png > measured-colors.json
@@ -102,7 +104,9 @@ node scripts/measure-colors.mjs reference.png > measured-colors.json
node scripts/verify.mjs implementation.png measured-colors.json
```
`measure-colors.mjs` 對實際像素執行確定性 k-means 分群(透過感知 ΔE 合併反鋸齒雜訊),輸出精確的十六進位色彩、覆蓋率,以及背景/文字/強調色角色。`verify.mjs` 會重新測量生成結果,回報各色彩的 ΔE 與覆蓋率偏差,並給出 PASS/FAIL 結果,讓代理人能自行校正,而不必依賴使用者目測。參考素材為圖片檔案時,技能會指示代理人自動使用這兩個腳本;不需 API 金鑰
透過「快速安裝」安裝為代理人技能時,代理人應以已載入的 `SKILL.md` 所在絕對目錄為基準解析這些腳本;使用者無需在自己的專案根目錄中準備 `scripts/` 目錄
`measure-colors.mjs` 對實際像素執行確定性 k-means 分群(透過感知 ΔE 合併反鋸齒雜訊),輸出精確的十六進位色彩、以 `0..1` 比例表示的覆蓋率、背景/文字/強調色角色,以及分群參數 `k``verify.mjs` 會複用記錄的 `k`,重新測量生成結果,回報各色彩的 ΔE 與覆蓋率偏差,並給出 PASS/FAIL 結果,讓代理人能自行校正,而不必依賴使用者目測。參考素材為圖片檔案時,技能會指示代理人自動使用這兩個腳本;不需 API 金鑰。
同一份參考bun.sh 首屏)、同一個代理人——感知重建與測量重建的比較:
+7 -7
View File
@@ -52,12 +52,12 @@ When the user provides images, screenshots, or links representing a target desig
**Analysis approach per dimension:**
#### Dimension 1: design_system
- **color**: Do not estimate hex values by eye — perceived colors drift toward familiar palette defaults (often by a ΔE of 10+). When the reference is an image file, measure instead:
- **color**: Do not estimate hex values by eye — perceived colors drift toward familiar palette defaults (often by a ΔE of 10+). When the reference is an image file, measure instead. Resolve `SKILL_ROOT` to the absolute directory containing this `SKILL.md`; never assume the current project directory contains the skill's `scripts/` folder. Use absolute paths for the reference and output, and keep one uniquely named measurement file per reference:
```bash
cd scripts && npm install --silent && cd ..
node scripts/measure-colors.mjs reference.png > measured-colors.json
npm install --prefix "$SKILL_ROOT/scripts" --silent
node "$SKILL_ROOT/scripts/measure-colors.mjs" "$REFERENCE_IMAGE" > "$MEASUREMENT_JSON"
```
Use the measured hexes verbatim in the DNA JSON: map the `background` role to `surface.background`, `text` to the darkest neutral, `accent` to `accent.hex`, and keep the full measured palette (with coverage percentages) in `design_system.color.measured_palette` for traceability. Only fall back to visual sampling when a measurement is impossible (e.g. URL-only references you cannot screenshot). Primary by area dominance, secondary by supporting role, accent by CTA usage. Map neutral scale from lightest background to darkest text.
Use the measured hexes verbatim in the DNA JSON: map the `background` role to `surface.background`; map `text` to the end of `neutral.scale` that contrasts with the measured background and document that foreground use in `neutral.usage`; and map `accent` to `accent.hex`. Keep both the measured palette and its `measurement` configuration in `design_system.color.measured_palette` and `design_system.color.measurement` so verification can reuse the same clustering configuration. Coverage values are fractions from `0` to `1`. Only fall back to visual sampling when measurement is impossible (for example, a URL-only reference that cannot be screenshotted). Choose primary and secondary colors by semantic role, use accent for CTA emphasis, and order the neutral scale from lightest to darkest regardless of theme.
- **typography**: Identify font families by visual characteristics (geometric, humanist, serif class). Estimate scale ratios from heading/body size relationships.
- **spacing**: Assess density by element proximity. Measure rhythm by section gap consistency.
- **layout**: Identify grid by content alignment patterns. Note max-width, column count, asymmetry.
@@ -92,11 +92,11 @@ When the user provides DNA JSON + content to design:
- Heavy effects → Three.js, custom GLSL shaders, Pixi.js
7. Generate the design output (default: self-contained HTML with inline CSS/JS)
8. Run quality checks from the generation guide
9. **Verify (when the reference was an image)**: screenshot the generated output, then score it against the measured reference palette:
9. **Verify (when the DNA contains a measured palette)**: save the current Design DNA JSON if it is not already a file, screenshot the generated output, then score it against that DNA file. Resolve `SKILL_ROOT` from this `SKILL.md` and use absolute paths; do not assume a temporary file named `measured-colors.json` exists:
```bash
node scripts/verify.mjs implementation.png measured-colors.json
node "$SKILL_ROOT/scripts/verify.mjs" "$IMPLEMENTATION_SCREENSHOT" "$DESIGN_DNA_JSON"
```
The report gives per-color ΔE and coverage drift with PASS/FAIL thresholds. If it fails, fix the offending colors and re-verify instead of asking the user to judge fidelity by eye.
A standalone measurement JSON may be used instead of the DNA file when that is the only persisted artifact. For multiple image references, verify against each reference's measurement separately. The report gives per-color ΔE and coverage drift with PASS/FAIL thresholds. If it fails, fix the offending colors and re-verify instead of asking the user to judge fidelity by eye.
**If the user provides only content without DNA JSON**, ask whether to:
- Analyze a reference first (go to Phase 2)
+3 -2
View File
@@ -5,7 +5,7 @@ Three-dimensional design profile:
- **design_style** — qualitative perception
- **visual_effects** — special rendering such as Canvas, WebGL, 3D, particles, shaders, scroll effects, cursor effects, SVG animation, and glassmorphism
Every field below must appear in the final JSON output.
Every non-optional field below must appear in the final JSON output. Include optional fields only when their stated source data exists.
## Top-Level Structure
@@ -36,7 +36,8 @@ The structural and measurable layer.
- `surface.card`
- `surface.elevated`
- `contrast_strategy`
- `measured_palette` *(optional)* — when the reference was an image measured with `scripts/measure-colors.mjs`, the full measured palette (`hex`, `coverage`, `role` per entry) for traceability
- `measurement` *(optional)* — when the reference was measured with `scripts/measure-colors.mjs`, copy its measurement configuration (including `k`) so verification can reuse the same clustering settings
- `measured_palette` *(optional)* — when the reference was measured, copy its palette (`hex`, `coverage`, `role` per entry) for traceability; `coverage` is a `0..1` fraction
#### `design_system.typography`
- `type_scale.display.size`
+95 -16
View File
@@ -4,7 +4,7 @@
// 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
// percentages. The output is meant to be merged into `design_system.color` of
// fractions. The output is meant to be merged into `design_system.color` of
// a Design DNA JSON — exact hexes with evidence, not guesses.
//
// Usage:
@@ -13,6 +13,7 @@
// Output (stdout): JSON
// {
// "source": { "file", "width", "height" },
// "measurement": { "k", "sampling" },
// "palette": [ { "hex", "coverage", "role" } ... ],
// "measured": true
// }
@@ -22,13 +23,20 @@ import { basename, extname } from "node:path";
import { deltaE, hex, hsv } from "./color-math.mjs";
const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith("--"));
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 kIdx = args.indexOf("--k");
const K = kIdx >= 0 ? Math.max(2, Math.min(16, Number(args[kIdx + 1]) || 8)) : 8;
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 ----------
@@ -131,15 +139,24 @@ function assignRoles(clusters) {
entries[text].role = "text";
taken.add(text);
}
// accent: most saturated remaining color with ≥0.2% coverage; near-white and
// near-black clusters are excluded — they are surfaces/ink, not accents
// 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
)
.sort((a, b) => b.e.s * Math.sqrt(b.e.share) - a.e.s * Math.sqrt(a.e.share));
.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);
@@ -147,6 +164,63 @@ function assignRoles(clusters) {
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");
@@ -157,15 +231,12 @@ try {
console.error(`error: cannot read ${file}: ${err.message}`);
process.exit(1);
}
const MAX = 400; // downsample for clustering speed; colors are unaffected
const scale = Math.min(1, MAX / Math.max(width, height));
const w = Math.max(1, Math.round(width * scale));
const h = Math.max(1, Math.round(height * scale));
const raw = await img.resize(w, h, { kernel: "nearest" }).raw().toBuffer();
const pixels = [];
for (let i = 0; i + 2 < raw.length; i += 3) {
pixels.push([raw[i], raw[i + 1], raw[i + 2]]);
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
@@ -176,6 +247,14 @@ 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)),
+3
View File
@@ -6,6 +6,9 @@
"node": ">=18.17"
},
"description": "Optional deterministic measurement scripts for the design-dna skill",
"scripts": {
"test": "node --test test/color-measurement.test.mjs"
},
"dependencies": {
"sharp": "^0.33.5"
}
+263
View File
@@ -0,0 +1,263 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import test from "node:test";
import sharp from "sharp";
import { deltaE, parseHex } from "../color-math.mjs";
const execFileAsync = promisify(execFile);
const scriptsDir = dirname(dirname(fileURLToPath(import.meta.url)));
const measureScript = join(scriptsDir, "measure-colors.mjs");
const verifyScript = join(scriptsDir, "verify.mjs");
async function createTempDir(t) {
const dir = await mkdtemp(join(tmpdir(), "design-dna-colors-"));
t.after(() => rm(dir, { recursive: true, force: true }));
return dir;
}
async function run(script, args, expectedCode = 0) {
try {
const result = await execFileAsync(process.execPath, [script, ...args], {
cwd: scriptsDir,
maxBuffer: 10 * 1024 * 1024,
});
assert.equal(expectedCode, 0, `expected exit ${expectedCode}, got 0`);
return result;
} catch (error) {
assert.equal(error.code, expectedCode, error.stderr || error.message);
return { stdout: error.stdout, stderr: error.stderr };
}
}
async function measure(image, args = []) {
const { stdout } = await run(measureScript, [image, ...args]);
return JSON.parse(stdout);
}
async function saveSpec(dir, name, spec) {
const file = join(dir, name);
await writeFile(file, JSON.stringify(spec));
return file;
}
async function verify(image, specFile, expectedCode = 0) {
const { stdout } = await run(verifyScript, [image, specFile], expectedCode);
return JSON.parse(stdout);
}
async function writeRgb(file, width, height, pixelAt) {
const data = Buffer.alloc(width * height * 3);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const offset = (y * width + x) * 3;
const color = pixelAt(x, y);
data[offset] = color[0];
data[offset + 1] = color[1];
data[offset + 2] = color[2];
}
}
await sharp(data, { raw: { width, height, channels: 3 } }).png().toFile(file);
}
test("measure -> verify reuses the persisted k for plain, embedded, and legacy specs", async (t) => {
const dir = await createTempDir(t);
const image = join(dir, "gradient.png");
await writeRgb(image, 400, 80, (x) => {
const amount = x / 399;
return [Math.round(255 * (1 - amount)), 0, Math.round(255 * amount)];
});
const defaultMeasurement = await measure(image);
assert.equal(defaultMeasurement.measurement.k, 8);
const defaultSpec = await saveSpec(dir, "default.json", defaultMeasurement);
assert.equal((await verify(image, defaultSpec)).pass, true);
const legacyMeasurement = structuredClone(defaultMeasurement);
delete legacyMeasurement.measurement;
const legacySpec = await saveSpec(dir, "legacy.json", legacyMeasurement);
const legacyReport = await verify(image, legacySpec);
assert.equal(legacyReport.measurement.k, 8);
assert.equal(legacyReport.pass, true);
const customMeasurement = await measure(image, ["--k", "3"]);
assert.equal(customMeasurement.measurement.k, 3);
const customSpec = await saveSpec(dir, "custom.json", customMeasurement);
const customReport = await verify(image, customSpec);
assert.equal(customReport.measurement.k, 3);
assert.equal(customReport.pass, true);
const embeddedSpec = await saveSpec(dir, "embedded.json", {
design_system: {
color: {
measured_palette: customMeasurement.palette,
measurement: customMeasurement.measurement,
},
},
});
const embeddedReport = await verify(image, embeddedSpec);
assert.equal(embeddedReport.measurement.k, 3);
assert.equal(embeddedReport.pass, true);
});
test("a significant wrong implementation cluster cannot be averaged away", async (t) => {
const dir = await createTempDir(t);
const reference = join(dir, "white.png");
const implementation = join(dir, "white-with-black.png");
await writeRgb(reference, 100, 100, () => [255, 255, 255]);
await writeRgb(implementation, 100, 100, (x) =>
x < 4 ? [0, 0, 0] : [255, 255, 255]
);
const specFile = await saveSpec(dir, "white.json", await measure(reference));
const report = await verify(implementation, specFile, 2);
const black = report.implementationClusters.find((c) => c.imageHex === "#000000");
assert.ok(black);
assert.equal(black.imageCoverage, 0.04);
assert.ok(black.deltaE > 90);
assert.ok(report.maxDeltaE > 90);
assert.equal(report.pass, false);
});
test("a large wrong region matched to a rare reference color still fails", async (t) => {
const dir = await createTempDir(t);
const reference = join(dir, "rare-black.png");
const implementation = join(dir, "large-gray.png");
await writeRgb(reference, 100, 100, (x, y) =>
y * 100 + x < 40 ? [0, 0, 0] : [255, 255, 255]
);
await writeRgb(implementation, 100, 100, (x, y) =>
y * 100 + x < 1750 ? [85, 85, 85] : [255, 255, 255]
);
const specFile = await saveSpec(dir, "rare.json", await measure(reference));
const report = await verify(implementation, specFile, 2);
const gray = report.implementationClusters.find((c) => c.imageHex === "#555555");
assert.ok(gray);
assert.equal(gray.imageCoverage, 0.175);
assert.ok(gray.deltaE > 20);
assert.ok(report.coverageDrift < report.thresholds.coverageDrift);
assert.equal(report.pass, false);
});
test("a missing significant reference color is checked in the reverse direction", async (t) => {
const dir = await createTempDir(t);
const reference = join(dir, "black-and-white.png");
const implementation = join(dir, "only-white.png");
await writeRgb(reference, 100, 100, (x) =>
x < 10 ? [0, 0, 0] : [255, 255, 255]
);
await writeRgb(implementation, 100, 100, () => [255, 255, 255]);
const specFile = await saveSpec(dir, "black-and-white.json", await measure(reference));
const report = await verify(implementation, specFile, 2);
const missingBlack = report.entries.find((entry) => entry.specHex === "#000000");
assert.ok(missingBlack);
assert.equal(missingBlack.nearestImageHex, "#ffffff");
assert.ok(missingBlack.deltaE > 90);
assert.equal(report.pass, false);
});
test("a sub-percent remnant does not preserve a significant reference color", async (t) => {
const dir = await createTempDir(t);
const reference = join(dir, "ten-percent-black.png");
const implementation = join(dir, "trace-black.png");
await writeRgb(reference, 100, 100, (x, y) =>
y * 100 + x < 1000 ? [0, 0, 0] : [255, 255, 255]
);
await writeRgb(implementation, 100, 100, (x, y) =>
y * 100 + x < 10 ? [0, 0, 0] : [255, 255, 255]
);
const specFile = await saveSpec(dir, "reference.json", await measure(reference));
const report = await verify(implementation, specFile, 2);
const missingBlack = report.entries.find((entry) => entry.specHex === "#000000");
assert.ok(missingBlack);
assert.equal(missingBlack.imageCoverage, 0.001);
assert.equal(missingBlack.nearestImageHex, "#ffffff");
assert.ok(missingBlack.deltaE > 90);
assert.ok(report.coverageDrift < report.thresholds.coverageDrift);
assert.equal(report.pass, false);
});
test("entry deltaE always describes its nearestImageHex", async (t) => {
const dir = await createTempDir(t);
const reference = join(dir, "reference.png");
const implementation = join(dir, "implementation.png");
await writeRgb(reference, 100, 100, (x) =>
x < 50 ? [0, 0, 0] : [255, 255, 255]
);
await writeRgb(implementation, 100, 100, (x) => {
if (x < 25) return [20, 20, 20];
if (x < 50) return [80, 80, 80];
return [245, 245, 245];
});
const specFile = await saveSpec(dir, "reference.json", await measure(reference));
const report = await verify(implementation, specFile, 2);
for (const entry of report.entries) {
const expected = Number(
deltaE(parseHex(entry.specHex), parseHex(entry.nearestImageHex)).toFixed(2)
);
assert.equal(entry.deltaE, expected);
}
});
test("bounded sampling preserves both colors in alternating 1 px stripes", async (t) => {
const dir = await createTempDir(t);
const stripes = join(dir, "stripes.png");
await writeRgb(stripes, 800, 400, (x) =>
x % 2 === 0 ? [255, 0, 0] : [0, 0, 255]
);
const result = await measure(stripes);
assert.equal(result.measurement.sampling.sampled_pixels, 160_000);
const red = result.palette.find((entry) => entry.hex === "#ff0000");
const blue = result.palette.find((entry) => entry.hex === "#0000ff");
assert.ok(red, "red stripe color should be sampled");
assert.ok(blue, "blue stripe color should be sampled");
assert.ok(red.coverage > 0.48 && red.coverage < 0.52);
assert.ok(blue.coverage > 0.48 && blue.coverage < 0.52);
});
test("accent assignment favors perceptual separation over tinted surface area", async (t) => {
const dir = await createTempDir(t);
const image = join(dir, "dark-dashboard.png");
await writeRgb(image, 100, 100, (x, y) => {
const index = y * 100 + x;
if (index < 250) return [243, 246, 254];
if (index < 500) return [110, 231, 255];
if (index < 1300) return [27, 37, 63];
return [15, 21, 38];
});
const result = await measure(image);
assert.equal(result.palette.find((entry) => entry.role === "text")?.hex, "#f3f6fe");
assert.equal(result.palette.find((entry) => entry.role === "accent")?.hex, "#6ee7ff");
assert.equal(
result.palette.find((entry) => entry.hex === "#1b253f")?.role,
"unassigned"
);
});
test("coverage drift reports enough precision to explain a boundary failure", async (t) => {
const dir = await createTempDir(t);
const reference = join(dir, "reference.png");
const implementation = join(dir, "implementation.png");
await writeRgb(reference, 100, 100, (x, y) =>
y * 100 + x < 10 ? [0, 0, 0] : [255, 255, 255]
);
await writeRgb(implementation, 100, 100, (x, y) =>
y * 100 + x < 1761 ? [0, 0, 0] : [255, 255, 255]
);
const specFile = await saveSpec(dir, "reference.json", await measure(reference));
const report = await verify(implementation, specFile, 2);
assert.equal(report.coverageDrift, 0.3502);
assert.equal(report.thresholds.coverageDrift, 0.35);
assert.equal(report.pass, false);
});
+89 -26
View File
@@ -33,16 +33,23 @@ if (!Array.isArray(specPalette)) {
process.exit(1);
}
const configuredK = spec.measurement?.k ?? spec.design_system?.color?.measurement?.k;
const parsedK = Number(configuredK);
const measurementK = Number.isFinite(parsedK)
? Math.max(2, Math.min(16, Math.trunc(parsedK)))
: 8;
// re-measure the implementation with the same deterministic pipeline
const here = dirname(fileURLToPath(import.meta.url));
const out = execFileSync(
process.execPath,
[join(here, "measure-colors.mjs"), imgFile, "--k", String(Math.min(16, Math.max(specPalette.length + 4, 8)))],
[join(here, "measure-colors.mjs"), imgFile, "--k", String(measurementK)],
{ encoding: "utf8" }
);
const impl = JSON.parse(out);
const dE = (a, b) => deltaE(parseHex(a), parseHex(b));
const SIGNIFICANT_COVERAGE = 0.005;
// Partition the implementation's clusters by their nearest spec color, so a
// spec color that re-measures as several nearby clusters is credited with
@@ -57,43 +64,90 @@ for (const c of impl.palette) {
assigned[best].push({ ...c, deltaE: bd });
}
const significantImagePalette = impl.palette.filter(
(c) => c.coverage >= SIGNIFICANT_COVERAGE
);
const entries = specPalette.map((s, i) => {
const group = assigned[i];
const cov = group.reduce((t, g) => t + g.coverage, 0);
let de, nearest;
if (group.length > 0 && cov > 0) {
de = group.reduce((t, g) => t + g.deltaE * g.coverage, 0) / cov;
nearest = group.sort((a, b) => a.deltaE - b.deltaE)[0].hex;
} else {
let bd = Infinity;
for (const c of impl.palette) {
const d = dE(s.hex, c.hex);
if (d < bd) (bd = d), (nearest = c.hex);
// A significant reference color must match a significant implementation
// cluster. A sub-percent remnant does not count as preserving the color.
const candidates =
s.coverage >= SIGNIFICANT_COVERAGE && significantImagePalette.length > 0
? significantImagePalette
: impl.palette;
let nearest;
let nearestDE = Infinity;
for (const c of candidates) {
const distance = dE(s.hex, c.hex);
if (distance < nearestDE) {
nearestDE = distance;
nearest = c.hex;
}
de = bd;
}
return {
specHex: s.hex,
role: s.role,
nearestImageHex: nearest,
deltaE: Number(de.toFixed(2)),
// deltaE always describes the displayed nearestImageHex. Coverage remains
// the total of all implementation clusters assigned to this spec color.
deltaE: Number(nearestDE.toFixed(2)),
specCoverage: s.coverage,
imageCoverage: Number(cov.toFixed(4)),
};
});
// coverage-weighted mean ΔE + coverage drift
let meanDE = 0, drift = 0, wsum = 0;
for (const e of entries) {
meanDE += e.deltaE * e.specCoverage;
drift += Math.abs(e.specCoverage - e.imageCoverage);
wsum += e.specCoverage;
// Keep every implementation cluster separate for color-error scoring. If a
// wrong color occupies meaningful image area, averaging it into a nearby
// reference group must not hide it.
const implementationClusters = impl.palette.map((c) => {
let nearestSpec;
let nearestRole;
let nearestDE = Infinity;
for (const s of specPalette) {
const distance = dE(s.hex, c.hex);
if (distance < nearestDE) {
nearestDE = distance;
nearestSpec = s.hex;
nearestRole = s.role;
}
}
return {
imageHex: c.hex,
imageCoverage: c.coverage,
nearestSpecHex: nearestSpec,
nearestSpecRole: nearestRole,
deltaE: Number(nearestDE.toFixed(2)),
};
});
// Mean ΔE is weighted by what is actually present in the implementation.
// Coverage drift and the reference-side max still expose omitted colors.
let meanDE = 0, imageWeight = 0, drift = 0;
for (const c of implementationClusters) {
meanDE += c.deltaE * c.imageCoverage;
imageWeight += c.imageCoverage;
}
meanDE = wsum > 0 ? meanDE / wsum : 0;
// max ΔE considers only colors with meaningful coverage (≥0.5%) so a stray
// sub-percent cluster can't fail an otherwise faithful implementation
const significant = entries.filter((e) => e.specCoverage >= 0.005);
const maxDE = Math.max(...(significant.length ? significant : entries).map((e) => e.deltaE));
for (const e of entries) {
drift += Math.abs(e.specCoverage - e.imageCoverage);
}
meanDE = imageWeight > 0 ? meanDE / imageWeight : 0;
const significantImplementation = implementationClusters.filter(
(c) => c.imageCoverage >= SIGNIFICANT_COVERAGE
);
const significantReference = entries.filter(
(e) => e.specCoverage >= SIGNIFICANT_COVERAGE
);
const maxImplementationDE = Math.max(
0,
...significantImplementation.map((c) => c.deltaE)
);
// The reverse direction is needed when a significant reference color is
// absent from the implementation and therefore has no image cluster to score.
const maxReferenceDE = Math.max(0, ...significantReference.map((e) => e.deltaE));
const maxDE = Math.max(maxImplementationDE, maxReferenceDE);
const pass = meanDE <= 5 && maxDE <= 20 && drift <= 0.35;
console.log(
@@ -101,11 +155,20 @@ console.log(
{
implementation: imgFile,
reference: specFile,
measurement: { k: measurementK },
entries,
implementationClusters,
meanDeltaE: Number(meanDE.toFixed(2)),
maxDeltaE: Number(maxDE.toFixed(2)),
coverageDrift: Number(drift.toFixed(2)),
thresholds: { meanDeltaE: 5, maxDeltaE: 20, coverageDrift: 0.35 },
// Keep enough precision for the report to explain boundary failures
// such as 0.3502 > 0.35.
coverageDrift: Number(drift.toFixed(4)),
thresholds: {
meanDeltaE: 5,
maxDeltaE: 20,
coverageDrift: 0.35,
significantCoverage: SIGNIFICANT_COVERAGE,
},
pass,
},
null,
@@ -113,6 +176,6 @@ console.log(
)
);
console.error(
`${pass ? "PASS" : "FAIL"} — mean ΔE ${meanDE.toFixed(2)}, max ΔE ${maxDE.toFixed(2)}, coverage drift ${drift.toFixed(2)}`
`${pass ? "PASS" : "FAIL"} — mean ΔE ${meanDE.toFixed(2)}, max ΔE ${maxDE.toFixed(2)}, coverage drift ${drift.toFixed(4)}`
);
process.exit(pass ? 0 : 2);