自己搞了个照片转拼豆图纸的微信小程序,目前只有一人访问,分享下开发过程,希望可以来票票

0 阅读12分钟

你有没有想过,把一张自拍变成 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++ 解决方案:

  1. 初始化 优化:第一个中心随机选,后续中心选择概率正比于"到已有中心的距离",保证初始中心分散
  2. 迭代聚类:将每个像素分配到最近的中心,再更新中心为簇内均值
  3. 在 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 方案演进

版本方案问题
v1mediaCheckAsync 异步API需配置消息推送回调,开发环境难以调试
v2imgSecCheck + 云存储上传下载云函数3秒超时
v3imgSecCheck + 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(放行),不阻断正常使用。因为内容安全是"加分项"而非"核心功能",用户体验优先。


四、色卡数据

内置两个主流拼豆品牌的色卡:

品牌色数产地规格
Perler48色美国5mm标准豆
Artkal C52色中国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'

解决:

  1. 先在云开发控制台删除残留函数
  2. 本地 npm install wx-server-sdk 安装依赖
  3. 右键选择"上传并部署:所有文件"(不是"云端安装依赖")

9.4 API 返回字段名

问题: 云调用返回 errcode: 0, errmsg: "ok" 但没有 trace_id,代码误判为失败。

解决: 兼容 camelCase 和 snake_case 两种格式(result.trace_id || result.traceId)。


十、总结

这个项目的核心不是 UI,而是色彩科学在工程中的落地:

  1. 色彩空间选择:RGB 不够,Lab 才是感知均匀的
  2. 色差公式选择:CIEDE2000 比 Lab 欧氏距离更精确,尤其蓝色区域
  3. 量化策略:K-means++ 聚类 + CIEDE2000 匹配,两阶段兼顾速度和精度
  4. 抖动算法:Floyd-Steinberg 用空间混色弥补颜色数量的不足
  5. 工程实践:缓存、预计算、base64 直传,每一步优化都有实际意义

完整的色彩匹配管线,从一张照片到一张可执行的拼豆图纸,中间是数十年的色彩科学研究。这就是工程的魅力——把学术理论变成可用的产品。

感兴趣的可以 扫扫看看,带一波人气,不然看着这个访问数据有点气人

22ba3045887725a16786e24057dc429b.jpg