你有没有想过,把一张自拍变成 29×29 的拼豆图纸,背后需要多少色彩科学?本文记录了一个微信小程序 的完整开发过程——从 RGB 到 CIE Lab 色彩空间转换,到 CIEDE2000 色差公式精确匹配,再到 K-means++ 聚类选色和 Floyd-Steinberg 抖动算法。这不是简单的"缩放图片",而是一个完整的色彩工程。
一、项目概述
1.1 它是什么
「照片转拼豆图纸」是一个微信小程序,用户上传一张照片,选择拼豆品牌和底板尺寸,程序会将照片像素化并在拼豆色卡中找到最接近的颜色,最终生成带色号标注的网格图纸和材料清单。
核心数据流:
用户选图 → 内容安全检测 → 中心裁剪+缩放 → K-means++聚类 选色
→ CIEDE2000色卡匹配 → Floyd-Steinberg抖动(可选) → 网格图纸+材料清单
1.2 功能清单
| 功能模块 | 说明 |
|---|---|
| 首页选图 | 从相册/相机选取图片,支持压缩 |
| 内容安全检测 | 调用微信 imgSecCheck API,拦截违规图片 |
| 品牌选择 | Perler 48色 / Artkal C 52色 |
| 底板尺寸 | 15×15 / 29×29 / 50×50 / 80×80 |
| 颜色数量 | 8-32色滑块调节 |
| 图像增强 | 可选对比度增强 + 饱和度增强 |
| 抖动开关 | 可选 Floyd-Steinberg 误差扩散抖动 |
| 图纸预览 | Canvas 绘制,支持网格线/色号标注/颜色高亮 |
| 材料清单 | 每种颜色的色号、名称、数量统计 |
| 保存分享 | 长按保存到相册,分享好友/朋友圈 |
二、核心技术解析
2.1 为什么不用 RGB 距离?
很多人第一反应是:找最接近的颜色,算 RGB 距离不就行了?
ΔR = R1 - R2, ΔG = G1 - G2, ΔB = B1 - B2
distance = √(ΔR² + ΔG² + ΔB²)
问题在于 RGB 空间不符合人眼感知。看这个例子:
| 颜色对 | RGB距离 | 人眼感知 |
|---|---|---|
| 深红 vs 浅红 | 较大 | 很接近 |
| 绿色 vs 青色 | 较小 | 差别明显 |
RGB 空间是"设备空间",为显示器设计,不是为"人眼"设计的。同样数值的色差,在不同色域区域,人眼感受的差异完全不同。
2.2 CIE Lab 色彩空间
CIE Lab 是"感知均匀空间"——在这个空间中,相同的数值距离对应相同的人眼感知差异。
转换链路:RGB → XYZ → Lab
// sRGB → 线性RGB (逆Gamma校正)
function srgbToLinear(c) {
c = c / 255
return c > 0.04045 ? Math.pow((c + 0.055) / 1.055, 2.4) : c / 12.92
}
// RGB → XYZ (D65标准光源)
function rgbToXyz(r, g, b) {
const lr = srgbToLinear(r)
const lg = srgbToLinear(g)
const lb = srgbToLinear(b)
// sRGB D65 变换矩阵
const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375
const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.0721750
const z = lr * 0.0193339 + lg * 0.1191920 + lb * 0.9503041
return [x * 100 / 95.047, y * 100 / 100.0, z * 100 / 108.883]
}
// XYZ → CIE Lab
function xyzToLab(x, y, z) {
const fx = labF(x / 95.047)
const fy = labF(y / 100.0)
const fz = labF(z / 108.883)
const L = 116 * fy - 16
const a = 500 * (fx - fy)
const b = 200 * (fy - fz)
return [L, a, b]
}
Lab 空间的三个分量有明确物理意义:
- L(亮度):0=黑,100=白
- a(红绿轴):正=红,负=绿
- b(黄蓝轴):正=黄,负=蓝
2.3 CIEDE2000 —— 最精确的色差公式
有了 Lab 空间,可以直接算欧氏距离(ΔE_ab),但 CIE 在 2000 年发布了更精确的 CIEDE2000 公式,它额外考虑了:
- 亮度权重:人眼对暗色和亮色的区分能力不同
- 色度权重:饱和度越高,色差容限越大
- 色相旋转项(RT):蓝色区域有特殊的色相偏转
完整实现(核心部分):
function ciede2000(lab1, lab2) {
const [L1, a1, b1] = lab1
const [L2, a2, b2] = lab2
const kL = 1, kC = 1, kH = 1
// 步骤1: 计算调整后的色度C'和色相h'
const C1 = Math.sqrt(a1 * a1 + b1 * b1)
const C2 = Math.sqrt(a2 * a2 + b2 * b2)
const C_bar = (C1 + C2) / 2
const C_bar7 = Math.pow(C_bar, 7)
const G = 0.5 * (1 - Math.sqrt(C_bar7 / (C_bar7 + Math.pow(25, 7))))
const a1p = (1 + G) * a1
const a2p = (1 + G) * a2
// ... 色相角计算 ...
// 步骤2: 计算色差分量
const dLp = L2 - L1
const dCp = C2p - C1p
// ... ΔH' 计算 ...
// 步骤3: 权重函数(亮度、色度、色相各自的容限)
const Sl = 1 + (0.015 * Math.pow(Lp_bar - 50, 2)) / Math.sqrt(20 + Math.pow(Lp_bar - 50, 2))
const Sc = 1 + 0.045 * Cp_bar
const Sh = 1 + 0.015 * Cp_bar * T // T是色相旋转函数
// 步骤4: 交叉项 RT + 最终色差
const RT = -2 * Math.sqrt(...) * Math.sin(2 * dTheta)
const dE = Math.sqrt(
Math.pow(dLp / (kL * Sl), 2) +
Math.pow(dCp / (kC * Sc), 2) +
Math.pow(dHp / (kH * Sh), 2) +
RT * (dCp / (kC * Sc)) * (dHp / (kH * Sh))
)
return dE
}
实际效果对比(同一张图,8色):
| 匹配方式 | 结果 |
|---|---|
| RGB距离 | 肤色偏移严重,肤色发灰 |
| Lab欧氏距离 | 明显改善,但蓝色区域有偏 |
| CIEDE2000 | 色彩还原最准确,肤色自然 |
2.4 K-means++ 聚类选色
从几千个像素中选出 N 种代表性颜色,不是简单的"按使用频率排序"。
为什么不能用频率排序? 比如一张风景照,天空有 1000 个像素都是浅蓝,草地有 800 个像素都是绿色,但如果浅蓝之间有细微差异,频率排序可能选了 5 种几乎相同的浅蓝,浪费了颜色配额。
K-means++ 解决方案:
- 初始化 优化:第一个中心随机选,后续中心选择概率正比于"到已有中心的距离",保证初始中心分散
- 迭代聚类:将每个像素分配到最近的中心,再更新中心为簇内均值
- 在 Lab 空间聚类:用 Lab 欧氏距离(比 CIEDE2000 快,对聚类足够)
function kmeans(pixels, k, maxIterations = 12) {
// 1. 所有像素转Lab(带缓存,相同RGB只算一次)
const labs = new Array(n)
const labCache = {}
for (let i = 0; i < n; i++) {
const key = (p[0]|0) + '_' + (p[1]|0) + '_' + (p[2]|0)
if (labCache[key]) labs[i] = labCache[key]
else { labs[i] = rgbToLab(...); labCache[key] = labs[i] }
}
// 2. K-means++ 初始化
centers.push(labs[random].slice())
for (let c = 1; c < k; c++) {
// 每个点到已有中心的最近距离 → 概率选择
const r = Math.random() * totalDist
// ... 轮盘赌选择 ...
}
// 3. 迭代:分配 → 更新中心
for (let iter = 0; iter < maxIterations; iter++) {
// 分配每个点到最近中心
// 更新中心为簇内Lab均值
if (!changed) break // 收敛提前退出
}
}
聚类完成后,每个聚类中心代表一类颜色,再用 CIEDE2000 在色卡中匹配——两阶段策略:K-means 负责"选什么色",CIEDE2000 负责"匹配到哪个色号"。
2.5 Floyd-Steinberg 抖动
当颜色数量受限时,渐变区域会出现明显的色带。Floyd-Steinberg 抖动通过误差扩散解决:
当前像素 → 匹配到最近色 → 计算误差 → 将误差分配给周围像素
误差分配矩阵:
┌─────┬─────┬─────┐
│ │ ● │ 7/16│ → 右
├─────┬─────┼─────┤
│3/16 │5/16 │1/16 │ → 左下、正下、右下
└─────┴─────┴─────┘
实现:
if (useDithering) {
const buf = workPixels.map(p => [p[0], p[1], p[2]])
for (let y = 0; y < gridH; y++) {
for (let x = 0; x < gridW; x++) {
const idx = y * gridW + x
const oldR = buf[idx][0], oldG = buf[idx][1], oldB = buf[idx][2]
const matched = matchPixel(oldR, oldG, oldB)
grid[idx] = matched
// 误差 = 原色 - 匹配色
const errR = oldR - matched.rgb[0]
const errG = oldG - matched.rgb[1]
const errB = oldB - matched.rgb[2]
// 向右 7/16
if (x + 1 < gridW) distribute(idx + 1, 7/16)
// 左下 3/16
if (x > 0 && y + 1 < gridH) distribute((y+1)*gridW + x - 1, 3/16)
// 正下 5/16
if (y + 1 < gridH) distribute((y+1)*gridW + x, 5/16)
// 右下 1/16
if (x + 1 < gridW && y + 1 < gridH) distribute((y+1)*gridW + x + 1, 1/16)
}
}
}
效果: 抖动后,渐变区域通过两种颜色的像素交替排列产生"空间混色",人眼在远处看会感知到中间色,保留了更多层次细节。
2.6 图像预处理
在量化之前,对图像做增强处理:
// 自动对比度增强:拉伸直方图到 0-255 全范围
function autoContrast(pixels, intensity = 0.8) {
// 找到每个通道的 min/max
// 线性拉伸到 0-255
// 按 intensity 比例混合(避免过度增强)
}
// 饱和度增强:颜色更鲜艳
function enhanceSaturation(pixels, factor = 1.25) {
const gray = r * 0.299 + g * 0.587 + b * 0.114
r = gray + (r - gray) * factor
// ...
}
增强是可选的——有些照片本身色彩已经很好,过度增强反而失真。
三、内容安全检测
微信小程序上传图片需要合规检测。踩了不少坑,最终方案:
3.1 方案演进
| 版本 | 方案 | 问题 |
|---|---|---|
| v1 | mediaCheckAsync 异步API | 需配置消息推送回调,开发环境难以调试 |
| v2 | imgSecCheck + 云存储上传下载 | 云函数3秒超时 |
| v3 | imgSecCheck + base64直传 | ✅ 稳定可用 |
3.2 最终方案:客户端压缩 → base64 → 云函数同步检测
客户端(securityCheck.js):
function checkImageSafety(tempFilePath, onStatus) {
return new Promise(async (resolve) => {
// 1. 压缩图片(客户端)
const compressRes = await wx.compressImage({
src: tempFilePath, quality: 60, compressedWidth: 800
})
// 2. 读取为base64
const fileData = await wx.getFileSystemManager().readFile({
filePath: compressRes.tempFilePath, encoding: 'base64'
})
// 3. 调用云函数(直传base64,跳过云存储)
const checkRes = await wx.cloud.callFunction({
name: 'checkImage',
data: { base64Data: fileData, contentType: 'image/jpeg' }
})
resolve({ isSafe: checkRes.result.isSafe !== false })
})
}
云函数(checkImage/index.js):
exports.main = async (event, context) => {
const wxContext = cloud.getWXContext()
const { base64Data } = event
// base64 → Buffer
const imageBuffer = Buffer.from(base64Data, 'base64')
// 同步检测
const result = await cloud.openapi.security.imgSecCheck({
media: { contentType: 'image/jpeg', value: imageBuffer },
version: 2, scene: 1, openid: wxContext.OPENID
})
// 判断 detail[].suggest 是否为 'risky'
let isSafe = true
if (result.detail) {
for (const item of result.detail) {
if (item.suggest === 'risky') { isSafe = false; break }
}
}
return { errcode: 0, errmsg: 'ok', isSafe }
}
关键决策: 检测服务异常时fail-open(放行),不阻断正常使用。因为内容安全是"加分项"而非"核心功能",用户体验优先。
四、色卡数据
内置两个主流拼豆品牌的色卡:
| 品牌 | 色数 | 产地 | 规格 |
|---|---|---|---|
| Perler | 48色 | 美国 | 5mm标准豆 |
| Artkal C | 52色 | 中国 | 2.6mm |
色卡数据结构:
{ id: 'P-01', name: 'White', rgb: [248, 248, 248] }
{ id: 'P-06', name: 'Red', rgb: [205, 45, 40] }
{ id: 'C-31', name: 'Cyan', rgb: [0, 180, 210] }
每个颜色的 RGB 值在启动时预计算为 Lab 值缓存,避免每次匹配都重复转换。
五、Canvas 绘图
微信小程序使用 Canvas 2D API 绘制图纸:
function drawPattern(ctx, pattern, cellSize, showGrid, showLabels, canvasW, canvasH, highlightId) {
// 1. 绘制每个色块
for (let row = 0; row < gridH; row++) {
for (let col = 0; col < gridW; col++) {
const cell = grid[row * gridW + col]
ctx.fillStyle = `rgb(𝑐𝑒𝑙𝑙.𝑟𝑔𝑏[0],{cell.rgb[1]},${cell.rgb[2]})`
ctx.fillRect(col * cellSize, row * cellSize, cellSize, cellSize)
}
}
// 2. 网格线(每格白色细线)
if (showGrid) { /* ... */ }
// 3. 色号标注(自动黑白对比)
if (showLabels) {
const brightness = r * 0.299 + g * 0.587 + b * 0.114
ctx.fillStyle = brightness > 128 ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.85)'
ctx.fillText(String(cell.index), x, y)
}
// 4. 每5格加深蓝色线(方便定位)
ctx.strokeStyle = 'rgba(0,180,216,0.3)'
for (let i = 0; i <= gridW; i += 5) { /* ... */ }
}
交互功能:
- 点击材料清单中的颜色 → 图纸上该颜色高亮,其他变灰
- 网格线/色号标注可切换显示
- 长按保存到相册(需用户授权)
六、项目结构
照片转拼豆/
├── app.js # 云开发初始化
├── app.json # 路由配置
├── app.wxss # 全局样式(玻璃拟态主题)
├── pages/
│ ├── index/ # 首页:选图+参数设置
│ └── result/ # 结果页:图纸预览+材料清单
├── utils/
│ ├── colorMatch.js # 色彩匹配引擎(RGB→Lab + CIEDE2000)
│ ├── imageProcess.js # 图像处理(K-means + 抖动 + Canvas绘制)
│ ├── palettes.js # 拼豆品牌色卡数据
│ └── securityCheck.js # 图片内容安全检测
├── cloudfunctions/
│ └── checkImage/ # 云函数:imgSecCheck 同步检测
└── project.config.json # 项目配置(cloudfunctionRoot)
七、技术亮点总结
7.1 两阶段颜色选择策略
K-means++ 聚类 → 选出代表性颜色 → CIEDE2000 → 匹配到色卡色号
(快速分组) (精确匹配)
K-means 用 Lab 欧氏距离(快),最终匹配用 CIEDE2000(准)。不是所有步骤都需要最精确的算法,合理分配精度才能兼顾速度和质量。
7.2 匹配缓存
相同 RGB 的像素只匹配一次:
const matchCache = {}
function matchPixel(r, g, b) {
const key = ((r+0.5)|0) + '_' + ((g+0.5)|0) + '_' + ((b+0.5)|0)
if (matchCache[key]) return matchCache[key]
// ... CIEDE2000 计算 ...
matchCache[key] = bestMatch
return bestMatch
}
50×50 网格有 2500 个像素,但去重后可能只有 200-300 种颜色,缓存命中率极高。
7.3 Lab 预计算
色卡的 Lab 值在启动时一次性预计算:
const labPalette = colorMatch.precomputePaletteLab(palette)
// 48色 × 1次Lab转换 = 48次(而不是每像素48次)
7.4 每5格定位线
拼豆底板标准规格是 29×29(5格一组定位),图纸也对应每 5 格画一条加深的蓝色参考线,用户拼豆时可以快速定位行列位置。
八、效果展示与应用场景
8.1 应用场景
| 场景 | 说明 |
|---|---|
| 个人创作 | 将照片转为拼豆图案,制作钥匙扣、杯垫等 |
| 儿童教育 | 色彩认知、像素艺术启蒙 |
| 手工艺品 | 定制化拼豆作品,如头像、宠物照片 |
| 教学示例 | 色彩科学、图像处理的实践案例 |
8.2 效果对比
不同参数的输出差异:
| 参数 | 效果 |
|---|---|
| 8色 + 无抖动 | 色块分明,但渐变区域有断层 |
| 16色 + 抖动 | 细节丰富,渐变自然 |
| 32色 + 增强 | 色彩鲜艳,辨识度高 |
推荐配置:16色 + 抖动开启 + 增强开启,在色彩还原和拼豆可行性之间取得最佳平衡。
九、踩坑记录
9.1 云函数超时
问题: 使用异步 API mediaCheckAsync 需要配置消息推送回调,开发环境无法收到异步结果,程序一直卡在"等待检测结果"。
解决: 改用同步 API imgSecCheck,一次调用直接返回结果。
9.2 云存储下载超时
问题: 云函数先上传到云存储再下载,两步合计超过 3 秒默认超时。
解决: 客户端压缩后直接传 base64 给云函数,跳过云存储环节。
9.3 云函数部署失败
问题: 部署报 CreateFailed 状态 或 Cannot find module 'wx-server-sdk'。
解决:
- 先在云开发控制台删除残留函数
- 本地
npm install wx-server-sdk安装依赖 - 右键选择"上传并部署:所有文件"(不是"云端安装依赖")
9.4 API 返回字段名
问题: 云调用返回 errcode: 0, errmsg: "ok" 但没有 trace_id,代码误判为失败。
解决: 兼容 camelCase 和 snake_case 两种格式(result.trace_id || result.traceId)。
十、总结
这个项目的核心不是 UI,而是色彩科学在工程中的落地:
- 色彩空间选择:RGB 不够,Lab 才是感知均匀的
- 色差公式选择:CIEDE2000 比 Lab 欧氏距离更精确,尤其蓝色区域
- 量化策略:K-means++ 聚类 + CIEDE2000 匹配,两阶段兼顾速度和精度
- 抖动算法:Floyd-Steinberg 用空间混色弥补颜色数量的不足
- 工程实践:缓存、预计算、base64 直传,每一步优化都有实际意义
完整的色彩匹配管线,从一张照片到一张可执行的拼豆图纸,中间是数十年的色彩科学研究。这就是工程的魅力——把学术理论变成可用的产品。
感兴趣的可以 扫扫看看,带一波人气,不然看着这个访问数据有点气人