English | 中文
把一张照片变成拼豆(Perler / Hama bead)图纸的两步网页工具:
- 像素化处理(降采样)——选图片、选像素大小、选色卡,实时预览像素化效果
- 色号匹配——把像素化结果落到真实色卡上,得到带色号的拼豆图纸
只有这两步:没有编辑器、没有导出、没有 AI 生成、没有付费墙。
| 第一步:像素化处理 | 第二步:色号匹配 |
|---|---|
![]() |
![]() |
(截图用内置示例场景 + Mard 291 色卡、80 格;方板之外的非等比区域留白,不拉伸。)
git clone https://github.com/857god/pindou-bead-pattern.git
cd pindou-bead-pattern
npm install
npm run dev # http://127.0.0.1:5310npm run build # 产出 dist/
npm run preview # 预览构建结果要求 Node ≥ 20.19(或 ≥ 22.12,跟 Vite 8 的要求一致)。没有后端,所有计算都在浏览器里完成,图片不会上传到任何地方。
图片 → 降采样到「像素大小 × 像素大小」的方板 → 区域合并 → 色彩简化 → 采样增强 → 空间平滑。 产出的是一张还没落到色卡上的像素网格(每个格子是任意 HEX 颜色)。
- 尺寸模型是方板:格数恒为
像素大小²,源图按比例缩放后居中,非等比区域留空(渲染成白色)。 所以这一步的预览是正方形。 - 「标准」= 智能推荐档位(按尺寸档位自动取值);「自定义」= 在标准基线上叠加 5 个滑块: 背景简化、颜色简化、色块清晰度、线条保留、柔和降噪。
在第一步的网格上:灰度检测 → 精确最近色(CIEDE2000 + k-d 树)→ 块面统一 → 匹配层平滑
→ 杂色清理(去杂色)。产出每个格子的色号(如 C29)与用量统计。
- 「标准」= 智能推荐档位;「自定义」= 手动调去杂色、匹配层平滑、块面统一、鲜艳度、明暗层次。
改动第二步的档位只重跑第二步;改动第一步的参数会重跑第一步(若当前在第二步,再重跑第二步)。
「标准」不是一套固定参数,而是按像素大小分档自动取值。档位边界是
< 64 → small、< 104 → medium、< 150 → large、≥ 150 → xlarge:
| 像素大小 | 结构层:背景简化 / 颜色简化 / 色块清晰度 / 线条保留 / 柔和降噪 | 匹配层:去杂色 / 匹配层平滑 / 块面统一 |
|---|---|---|
| 20-52 | 2 / 2 / 2 / 2 / 1 | 1 / 1 / 2 |
| 53-63 | 2 / 0 / 2 / 2 / 1 | 1 / 1 / 2 |
| 64-103 | 1 / 0 / 1 / 1 / 1 | 1 / 2 / 3 |
| 104-149 | 0 / 0 / 1 / 1 / 0 | 3 / 3 / 4 |
| ≥ 150 | 0 / 0 / 0 / 0 / 0 | 2 / 2 / 3 |
小尺寸需要更多简化(格子少,噪点相对更明显),大尺寸基本交还给算法本身。 鲜艳度与明暗层次在标准模式下恒为 1.0,肤色保护恒为关闭。
本仓库不含任何品牌色卡数据,只带 4 档内置合成色卡(17 / 40 / 152 / 300 色), 所以 clone 下来就能直接跑(此时默认选中色最全的 152 色那张)。
这个页面只开放 Mard 291 一张品牌色卡:外部数据里能找到它就只提供它,
其他品牌和内置合成色卡都不会再进入选择列表(只剩一个选项时界面写成一行文字,
不会摆一个只有单项的下拉)。想换或想加品牌色卡见
public/palettes/README.md:把符合
pindou-color-data 约定的目录放好,
用 PINDOU_COLOR_DATA_ROOT 指过去即可,开发服务器会把它挂到 /palettes/* 下。
色卡数据的来源与正确性由使用者自行负责——不同厂商、不同批次的色号与色值会有差异。
src/
├── algorithm/
│ ├── chain.mjs # 成像主链(从 App 页面机械抽取,后删除两步用不到的代码)
│ ├── stages.mjs # 两步入口:runStructureStage / runMatchStage
│ ├── palette-source.mjs # 色卡门面(内置合成 + 外部品牌)
│ ├── palettes.mjs # 内置合成色卡
│ ├── color-data.node.mjs # 外部色卡的 Node 侧读取(给 dev server 用)
│ ├── image.mjs # 图片解码
│ └── sample-scene.mjs # 内置示例场景
├── components/ # 两步的界面组件
└── render.mjs # 画布绘制
scripts/smoke.mjs # 端到端冒烟(不开浏览器就能跑完两步)
test/ # node:test 测试
src/algorithm/chain.mjs 是从「趣拼豆」App 的 smart-pixel-generator 页面
机械抽取出来的:只把页面方法里的 this. 替换成模块级上下文的 ctx.,不改算法逻辑。
抽取时做过这些校验:
- 主链函数的函数体与源文件逐行比对,完全一致(抽取脚本自带回比);
- 页面里没有的运行时(
uni.*、canvas 2D、页面 data/computed)用平台 stub 补齐, canvas stub 是纯 JS 实现(含双线性重采样),所以这套算法在 Node 里也能跑。
抽取之后,为了只给仓库留下两步流程真正会走到的代码,又删掉了 735 行(抽取结果的 21.5%)。 原则只有一条:只删「在这两步流程里证明不可达」或「功能已整体移除」的代码, 不动任何会影响结果的算法分支。
| 删除内容 | 行数 | 理由 |
|---|---|---|
经典降采样管线 pixelizeImageData + 选择它的开关 |
141 | 开关被硬编码成「始终走新管线」,那条分支永远进不去 |
一次性编排 generatePixelArt、regenerateFromPixelization |
155 | 本仓库按两步拆分调用,不再需要 |
预设系统 applyPresetConfig / getPresetDescription / updateDescription |
118 | 在源页面里也没有调用点 |
绘制 drawResult 与本层 doDrawResult 桩 |
63 | 本仓库由 src/render.mjs 直接画到 DOM canvas |
抖动 applyDithering / Floyd–Steinberg / Atkinson / calculateGradients / addError |
147 | 连同界面入口一起移除,理由见下 |
高档位专用的 applyTestColorQuantizationRgb |
28 | 只在区域合并档位 ≥10 时触发,属测试页残留 |
特征分析薄封装 analyzeImage / analyzePalette / shouldSkipFeatureAnalysis、isSkinTonePixelCached、finishPerformanceTrace |
59 | 依赖的 image-analyzer.js 未搬运;运行时实测调用次数为 0 |
| 以上各项残留的调用点与分支整理 | 24 |
怎么确认「删了但结果没变」:删除前后各跑一遍同一组场景(标准模式 52/96/150/200 格、 自定义结构档位、自定义匹配档位、同色系优先、灰度图、8 色小色卡),逐项比对 结构网格指纹、匹配色号指纹、色数、平均色差、孤立豆数 —— 除下面这一处外逐项完全一致, 匹配色号逐格 0 差异。确认「确实没人调用」的方式是把主链上下文的每个方法与取值器都包一层 计数器跑完整流程,看调用次数。
唯一变化的一处:把结构滑块拉满(背景简化 10 → 区域合并档位 12)时,原本会额外走一次 测试页用的颜色归一化;删掉后同一张图上从 23 色 / 平均色差 6.04 / 孤立 3 格变成 20 色 / 5.68 / 0 格(按本仓库用的两个代理指标是变好的)。其余所有档位逐格一致。
stages.mjs 把主链按源页面的两步拆开调用。拆分不改变结果:与「一次性跑完」对比,
在 48 格与 96 格下匹配结果的 RGBA 与色号逐格 0 差异。
node --test # 算法两阶段测试
node scripts/smoke.mjs 96 # 用真实色卡跑完两步并打印实测数据打开 ?autotest=1(例如 http://127.0.0.1:5310/?autotest=1):页面会自动加载内置示例场景、
跑完两步,并把实测数据写进 <pre id="autotest" data-status="ok">。不用打开控制台就能确认链路是否正常:
structure=52x52 occupied=1872 empty=832 structureElapsedMs=155
match=52x52 colors=21 mean=6.62 median=6.02 max=19.19 isolated=8 matchElapsedMs=50
(色数、色差、孤立豆是确定值,换机器也一样;*ElapsedMs 随机器与浏览器波动,仅供参考。)
data-status 为 error 时,节点里会带上错误信息;正常交互路径下的未捕获错误也会显示成页面顶部的横幅。
这是从 App 页面里只抽出两步(上传 → 降采样 → 下一步 → 色卡匹配)的版本, 其余功能一律没搬。为了不让看过原页面的人产生误解,差异列在这里:
去掉了:VIP 门禁(标准/自定义两种模式在这里都可以直接用)、像素编辑器、 导出(PNG / PDF 图纸)、AI 生成、云端接口、作品集与历史记录、各类缓存层,以及 抖动(Floyd–Steinberg / Atkinson)——实测它在拼豆成品上只会增加高频噪点 (96 格下孤立豆从 11 格涨到 350 格)、耗时还多 3.5 倍,而且原页面界面上本来也没有入口。
新增了这些原页面没有的东西:
?autotest=1自检节点:见上一节,方便在没有测试框架的情况下确认链路没坏。?testdrive=1交互自检:逐个点控件并断言「结果真的重算了」,用来抓「假控件」, 只在带这个参数时运行,正常使用零开销。
保持一致的部分:算法本身、两步的先后与可回退、标准档位随像素大小分级的取值表、 界面文案(含「孤色清理」「肤色自然」这类原页面用词)、方板留白(非等比区域留白而不是拉伸)、 以及第一步滑块的 350ms 防抖语义。
- 结构层里的「柔和降噪」(
noiseSmooth)、匹配层的「匹配层平滑」与「块面统一」这三个档位, 实测开到最大也几乎不改变结果——它们判断「邻域主色是否占优」,而在此之前区域合并 已经把平面区域抹平了,主色就是格子自己。界面保留了这些控件(与原页面一致), 但不要期待明显效果。真正有效的是孤色清理(去杂色):它把孤立豆(四邻都不同色的格子)从 约 1.4% 降到约 0.2%,代价是被强改颜色的格子色差会变大。 - 结构层的空间平滑在不改动聚类/区域合并档位时基本是空转(实测只影响 4 格)。
见 CONTRIBUTING.md。一句话:不要提交品牌色卡数据,改算法要给前后对比。
MIT。仓库内不含任何品牌色卡数据。

