像素画转拼豆图纸:技术文档
本文档面向有图像技术背景的读者,包含算法名称和详细技术细节。
整体管线架构
scss
上传图片 (PNG)
↓
┌─────────────────────────────────────┐
│ 阶段一:预处理与颜色量化 │
│ Canvas 像素数据 → K-Means++ 聚类 │
│ (减少颜色至 4-16 色) │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ 阶段二:网格检测与优化 │
│ 颜色跳变直方图 → 离群剔除 → 间隙填充 │
│ → 锚点传播 → 去重 │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ 阶段三:色块提取与后处理 │
│ 逐块众数取色 → 相似块合并 → 背景剔除 │
│ → 色库匹配 │
└─────────────────────────────────────┘
↓
渲染拼豆图纸 + 多格式导出
阶段一:预处理与颜色量化
步骤 1:图片加载与像素提取
图片通过 FileReader.readAsDataURL() 读取为 Data URL,加载到 Image 对象后绘制到离屏 Canvas。通过 CanvasRenderingContext2D.getImageData() 获取 RGBA 像素数组。
javascript
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data; // 连续的 RGBA 数组
步骤 2:K-Means++ 颜色聚类
算法名称:K-Means++ 聚类
算法细节:
-
K 值自动估算 (
autoDetectK):- 采样图像中非白色像素(白色判定阈值为
r,g,b ≥ 245) - 使用带线性 gamma 校正的 RGB 转 XYZ/Lab 转换计算色域范围
- 根据色域广度和像素总数估算 K 值在 4 到 16 之间
- 采样图像中非白色像素(白色判定阈值为
-
K-Means++ 初始化 (
kMeansPlusPlusInit):- 第一个中心点从像素中随机选取
- 后续中心点的选取概率与像素到已有最近中心点的距离平方成正比
- 使用累积分布采样(CDF sampling):
random() × totalDist,遍历找到对应索引
-
迭代聚类 (
kMeansQuantize):- 分配阶段:每个像素分配到欧氏距离最近的中心点
- 更新阶段:每个簇的中心点更新为簇内像素 RGB 均值
- 收敛条件:中心点位移为 0 或达到 50 轮最大迭代
- 去重:最终中心点匹配到调色板中最近颜色,过滤重复色号
- 若去重后不足 K 色,从色库全集中补充
javascript
// 核心距离计算
const dr = p.r - c.r, dg = p.g - c.g, db = p.b - c.b;
const d = dr * dr + dg * dg + db * db;
阶段二:网格检测与优化
步骤 3:颜色跳变直方图检测
算法名称 :颜色跳变直方图检测 (detectGridLinesFromQuantized)
算法细节:
-
从量化后的图像数据计算两个过渡数组:
- 垂直方向
transV:对于每一列x,统计有多少相邻行(y, y+1)的颜色不同 - 水平方向
transH:对于每一行y,统计有多少相邻列(x, x+1)的颜色不同
- 垂直方向
-
候选线筛选:过渡比例超过
threshold = 0.30的位置为候选线 -
聚类合并:候选点在
snapRadius = baseSize × 0.3范围内合并为一个簇(单遍扫描聚类),取计数最高的点 -
返回
linesX(X 方向分割线的 x 坐标数组)和linesY(Y 方向分割线的 y 坐标数组)
javascript
const ratio = transitionCount / totalRows; // 或 totalCols
if (ratio >= threshold) candidates.push(pos);
// 单遍扫描聚类
for (const pos of sortedCandidates) {
if (pos - lastCenter <= snapRadius) {
clusterCount[currentCluster]++;
} else {
clusters.push({ center: bestPos, count: maxCount });
currentCluster++; lastCenter = pos;
}
}
步骤 4:离群线剔除
算法名称 :中位数间距离群检测 (pruneOutliers)
算法细节:
- 计算所有内部线间距的中位数
medianGap - 尾部检查:从最后一条线往前遍历,若某线到边界的距离超过
medianGap × 2.5,则截断尾部 - 首部检查:同理从第一条线往后遍历
- 偏差检查:幸存线检查与
baseSize整数倍预期位置的偏差(deviation > baseSize × 0.4则剔除)
javascript
const deviation = Math.abs(linePos - expectedPos);
if (deviation > baseSize * 0.4) { /* 剔除 */ }
步骤 5:网格间隙填充
算法名称 :等间距插值填充 (fillGridGaps)
算法细节:
- 若无锚点(内部线为空),从起点按
baseSize等间距生成全部网格线 - 若有锚点,在相邻锚点之间按均匀步长插入缺失线:
javascript
const n = Math.round(gap / baseSize); // 应包含的段数
const step = gap / n; // 每段实际步长
for (let j = 1; j < n; j++) {
lines.push(anchor[i] + j * step);
}
步骤 6:BFS 锚点传播
算法名称 :BFS 锚点传播 + 局部搜索优化 (propagateFromAnchors)
算法细节:
- 初始化队列:所有检测到的锚点(
detectedLines)入队 - BFS 循环:从队列取出线 → 沿
+baseSize和-baseSize方向生成候选位置 - 对每个候选位置调用
findBestLine:在±baseSize × 0.15范围内搜索评分最高的位置 - 评分函数:基于该位置的颜色一致性(行/列内相邻像素颜色差异的倒数)
- 新找到的线加入已知集合和队列继续传播
- 最大扩展 500 根线,最后排序去重并加上边界
javascript
// BFS 核心
while (queue.length > 0) {
const anchor = queue.shift();
for (const dir of [+1, -1]) {
const candidate = anchor + dir * baseSize;
const best = findBestLine(candidate, baseSize * 0.15);
if (best !== null && !known.has(best)) {
known.add(best);
queue.push(best);
}
}
}
步骤 7:去重
算法名称 :间距去重 (dedupCloseLines)
- 若相邻线间距 <
baseSize × 0.6,且跳过该线不影响网格结构,则剔除
阶段三:色块提取与后处理
步骤 8:逐块众数取色
算法名称 :众数/多数投票取色 (extractBlockColors)
算法细节:
- 以
linesX[i]为左右边界、linesY[j]为上下边界划分子块 - 在子块内部取缩小了
min(宽, 高) × 0.15的 margin 区域进行采样 - 在 margin 区域内以
step = 1遍历所有像素,构建颜色直方图:{ "r,g,b": count } - 取 count 最大的颜色作为该块的代表色(众数取色)
- 若采样区域为空,回退取块中心点的颜色
javascript
const colorCounts = {};
for (const p of sampledPixels) {
const key = `${p.r},${p.g},${p.b}`;
colorCounts[key] = (colorCounts[key] || 0) + 1;
}
const mainColor = argmax(colorCounts);
步骤 9:相似块合并
算法名称 :连通分量标记合并 (consolidateSimilarBlocks)
算法细节:
- 使用 8 邻域标签传播(Two-Pass 风格)
- 对每个非透明块,检查上、左、左上、右上四个方向已处理邻居
- 邻居判定条件:曼哈顿色差 < 35
javascript
const colorDiff = Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2);
if (colorDiff < 35) { /* 赋予相同标签 */ }
- 合并后,每个标签组内的所有块的原始像素聚合后,统一通过
findNearestColorFromPalette匹配到调色板中最近的颜色
步骤 10:背景透明处理
算法名称:四方向射线扫描(无独立函数,内联于流程末尾)
算法细节:
- 收集画布四条边界上出现的颜色 ID → "外围色集合"
- 对每个外围色格子,进行四方向射线检查:
- 从格子位置向上/下/左/右逐个检查,看整条射线是否完全由同色或透明块组成
- 如果某一个方向的整条射线没有遇到异色,则该方向"无阻断"
- 判定:若四个方向全部被异色阻断 (即
1111)→ 这是内部孔洞,保留 - 否则(至少一个方向无阻断)→ 可以到达边界,排除(设为 TRANSPARENT)
javascript
// 四方向阻断检测(4-bit 编码:LRTB)
let blocked = 0b0000;
// 左扫描
for (let x = cellX - 1; x >= 0; x--)
if (grid[cellY][x].id !== targetId && grid[cellY][x].id !== 'TRANSPARENT')
{ blocked |= 0b1000; break; }
// 同理处理右(0100)、上(0010)、下(0001)
if (blocked === 0b1111) { /* 保留(内部孔洞) */ }
else { /* 排除(可到达边界) */ }
步骤 11:颜色匹配到色库
算法名称 :CIE76 Lab 色差匹配 (findNearestColorFromPalette)
算法细节:
- 亮色快速路径:灰度值 > 220 → 仅在灰度值 ≥ 200 的调色板子集中用 RGB 欧氏距离匹配
- 暗色精确路径:转换 RGB → XYZ → CIE-Lab,在完整调色板中用 Lab 空间欧氏距离匹配
RGB 转 Lab 转换公式:
- sRGB 线性化:
c > 0.04045 ? ((c + 0.055) / 1.055)^2.4 : c / 12.92 - RGB→XYZ(D65 白点):标准矩阵变换
- XYZ→Lab:
L = 116f(Y/Yn) - 16, a = 500(f(X/Xn) - f(Y/Yn)), b = 200(f(Y/Yn) - f(Z/Zn)) - 其中
f(t) = t > 0.008856 ? t^(1/3) : 7.787t + 16/116
javascript
const dist = dL * dL + dA * dA + dB * dB;
// 取 dist 最小的颜色
算法演进历史
以下按实际开发顺序记录每条技术路线的尝试动机、方案要点、遇到的具体问题和废弃原因。
初始方案:简单像素采样
直接缩放图片到目标尺寸(drawImage(img, 0, 0, countX, countY)),缩放到拼豆网格分辨率后逐像素读取 RGB,用 K-Means 聚类减色,最后匹配到色库。
方案要点:Canvas 缩放自带双线性插值,等同于自动下采样 + 平滑。
问题:
- 颜色暴增:用户上传的"像素画 PNG"实际是已放大的版本(如 29×26 原图被放大到 290×260),放大使用了双线性插值/抗锯齿算法,每个色块边缘产生了 2-3 层过渡色像素。这些过渡色通过 K-Means 聚类时形成独立颜色簇,导致最终颜色种类暴增(30+ 种对应不到 10 种原始色)。
- 网格不对齐:Canvas 缩放采样时,分割线无法精确对齐原图的色块边界。过渡带像素使得网格线落在了色块内部而非边界上,导致检测出的行列数偏大(如 26×29 被解析成 29×32),最终图纸比原图多了几行几列,整体比例被错误放大。
尝试 1:HSV 饱和度增强
在 RGB 空间对像素做饱和度提升(1.3~1.6 倍),试图让图片颜色更鲜艳、更易匹配色库。
方案要点:标准 HSV 色彩空间转换 → 调整 S 通道 → 转回 RGB。后改为直接 RGB 空间运算。
问题 :增强后画面整体发灰(亮度偏移),颜色并不自然。多次调整后仍然无法达标。最终完全移除该预处理步骤,改为直接使用原图颜色。
尝试 2:自适应降噪(邻域降噪平滑)
根据总拼豆格子数分 4 级强度做孤立点清理:单点孤立像素清除 → 2×2 小块过滤 → 连通区域 FloodFill 清理(≤3px)→ 邻域平滑迭代 → 最终极小区域清除。
方案要点:4 级自适应档位(<8000 豆轻量 / 8000-19999 中等 / 20000-39999 强 / ≥40000 超强),FloodFill 连通区域分析。
问题:优化过度------2×2 的细节色块(如眼睛的深灰色块)被当作噪点整体抹掉。调整为温和模式(仅清理绝对孤立单点,8 邻域最多 1 个同色邻居)后,只清理零散单噪点,但降噪效果有限。
尝试 3:Sobel 边缘检测 + 霍夫变换网格定位
用 Sobel 算子提取图像边缘 → 霍夫变换检测水平和垂直直线 → 聚类合并相近直线 → 直线交叉形成网格。
方案要点 :经典计算机视觉管线。Sobel 核 [-1,0,1; -2,0,2; -1,0,1],霍夫参数空间投票。
问题:像素画本身的特点决定了这个方案失败------像素块是纯色的,块内部没有任何边缘。Sobel 只检测到相邻异色块之间的边界,这些边界恰好就是需要找的网格线位置,但霍夫变换在稀疏边缘上的直线检测不稳定。而且对于过渡色区域,Sobel 检测到大量弱边缘噪声。
尝试 4:颜色过渡逐行/列检测
逐行扫描水平颜色跳变、逐列扫描垂直颜色跳变,记录所有颜色变化位置作为候选网格线。
方案要点 :detectHorizontalEdges() / detectVerticalEdges(),阈值 diff > 50,合并距离 < 3px 的候选线。
问题:对渐变和抗锯齿过于敏感------一个色块边界可能被拆成 2-3 条候选线(过渡带的起始和结束都被检测),导致网格线过多,且位置不准确。
尝试 5:手动框选 + 自动吸附 + 放大镜
弹框展示图片预览,用户手动拖拽框选一个色块大小。提供 5× 局部放大镜(140×140px 浮层)辅助精确定位,框选时实时显示预估参考线。鼠标靠近色块边缘时自动吸附(±6px 扫描半径,曼哈顿色差阈值 40)。
方案要点:Loupe(放大镜)、Snap-to-Edge(边缘吸附)、实时参考线预览。
问题:需要人工操作,用户体验不够自动化。框选仍容易有 1-2px 偏差,且对大面积图纸(50×50+ 格)的手动框选不友好。
尝试 6:四边角自动检测
从图像四条边缘(10% 和 90% 位置)扫描颜色突变点,记录水平和垂直方向的间距,取最常见间距作为正方形块大小。
方案要点 :autoDetectBlockSize() 扫描 top/bottom/left/right 四条扫描线,统计间距频率,取最高频间距。
问题:非均匀放大的像素画四边间距不一致------有的边界处是半格(如放大倍数不是整数),导致边角和内部检测出的块大小矛盾。而且纯靠边缘样本不足以推测内部网格。
尝试 7:自适应边缘检测 + 图片预处理
在前一步基础上加入图片预处理流程:将 RGB 量化到 4 个级别减少颜色数 → 对比度增强(暗区 1.3× / 亮区 1.2×)→ 局部窗口颜色统计分析 → 动态阈值边缘检测(threshold = max(20, std × 1.5))。
方案要点 :preprocessImage() 颜色量化增强差异;createAdaptiveEdgeMap() 自适应阈值(根据局部标准差),边缘宽度范围限制在 [baseSize × 0.3, baseSize × 2] 之间。
问题:边缘检测能较好识别色块边界,但色块内部有时也检测到弱边缘(纯色内部的像素波动),产生误检。而且边缘像素被排除后,采样区域缩小,颜色投票样本不足。
尝试 8:多点采样 + 多数投票取色
将单点采样改为 9 点采样(中心 + 4 角 + 4 边中点),以多数投票(majority voting)确定格子主色。
方案要点 :getCellMajorityColor() 9 点采样,排除边缘像素和白色背景,统计颜色直方图取最高频。回退策略:若无明显多数(占比 < 40%),退回到中心点采样。
问题:网格线本身位置不准时,多采样点仍然会落到边界过渡带上。需要先解决网格定位问题。
尝试 9:边缘强化预处理 + 块大小范围检测
在块大小检测前对图像做边缘强化预处理:RGB 量化到 32 级 + 1.5× 对比度 + 4 邻域边缘强度加权(边缘像素 ×1.3)。扫描线从 4 条增加到 7 条(10%/25%/40%/50%/60%/75%/90% 位置),取前 3 高频间距的加权均值作为块大小。
方案要点 :enhanceEdgesForDetection() 边缘强化;7 线扫描提高稳定性;均值代替最小值,避免块大小偏小。
问题:块大小检测准确度有提升,但仍是一个单一固定值。实际放大倍数可能非整数,需要更灵活的处理。
尝试 10:迭代网格检测 + 颜色一致性验证(detectGridByColorConsistency)
这是第一次尝试"检测→验证→调整→重复"的迭代网格优化范式。
方案要点:
generateInitialLines():根据块大小范围生成初始参考线findProblemCells():遍历所有色块,用 8×8 → 12×12 网格采样 + 16 级颜色量化,非主导色占比 > 25% 标记为"问题色块"detectColorTransition():在问题色块内检测颜色跳变方向(水平/垂直)和位置adjustLines():根据跳变位置移动或插入参考线- 重复迭代至多 20 轮
问题:
- 插入新线优先于移动旧线,导致网格线越来越多("线条比之前更密了")
- 块大小偏小(如实际 13-14px 被计算成 11px),小格子更容易触发问题判定
- 颜色分组阈值(16 级 / 32 级)的调整像跷跷板:太粗则相近色合并漏检,太细则噪音误报
尝试 11:优先移动而非添加 + 块大小范围验证
将调整策略从"增线"改为"移线":每条线检测颜色跳变位置后,优先移动最近的已有线到跳变处;只在间距 ≥ 1.2× 平均间距时插入新线。同时添加块大小范围验证,在范围内测试多个候选值,根据颜色一致性和边缘密度评分选最优。
方案要点 :verifyRange() 评分验证;移动策略优先于插入策略;范围控制在 1.5× 以内。
问题:基准块大小偏小的问题仍未根本解决------后续计算分割线时倾向于按小尺寸扩展开,导致每个格子仍然太小,问题色块多。
尝试 12:智能偏移测试 + 同行同列综合评分
不再盲目扩大 10%,而是测试多个偏移位置(±2, ±4, ±6, ±8, ±10px),对每个位置计算同行/列所有色块的平均颜色占比,选择综合评分最优的位置。
方案要点 :getRowColAverageRatio() 同行同列综合评估;findOptimalBoundary() 多候选点测试;最小色块限制(minSpacing × 1.0)。
问题:所有候选位置的 avgRatio 高度相近(如全是 0.378),无法区分哪个更好------最终最佳位置等于当前位置,等效于没有调整。核心原因是每个格子已经对齐到像素块边界,微调 1-2px 无法改善颜色一致性。
尝试 13:颜色跳变边界驱动扩张
在扩张分割线时,将颜色跳变像素作为边界停止条件:计算颜色占比时排除边缘跳变像素,但必须碰到跳变像素才停止扩张。同行或同列只要有一个色块碰到跳变即视为到达边界。
方案要点 :checkBoundaryHit() 同行/列任一色块触碰跳变即停;getColorRatioExcludingEdges() 排除边缘 2px 计算占比;优先选择碰到边界的候选位置。
问题:颜色跳变检测不够准确------图片中的过渡区域(抗锯齿产生的 2-3 层灰度像素)有时被判定为跳变,有时没有。跳变检测的一致性问题源于图片自身的质量。
尝试 14:预锐化 + 颜色跳变缓存(preSharpen + detectColorEdges)
在迭代开始前一次性完成锐化和边缘检测,缓存结果供后续所有轮次复用。使用 Sobel 算子检测边缘强度,对检测到的边缘线进行方向(水平/垂直)和长度(≥ 基准像素 × 0.8)验证。
方案要点 :preSharpenImage() Sobel 边缘增强;detectColorEdges() 水平/垂直边缘分别检测,长度保证;候选位置按距离排序优先测试边缘位置;缓存避免重复计算。
问题:锐化后产生的边缘线和实际网格线位置仍有偏移,且边缘线过于密集,需要大量过滤。
尝试 15:顺延传播(adjustLinesWithPropagation)
移动一条分割线时,该线之后的所有线条同步顺延相同偏移量;同一行的横向线标记为"已校验",避免重复处理。
方案要点 :adjustLinesWithPropagation() 带 checkedXLines/checkedYLines 校验标记;移动后所有后续线级联顺延;最多 20 轮内部迭代。
问题:顺延后线条索引变化,但计算仍用原始位置,导致后续轮次的偏移量累积错误。加上最小色块限制为 1.0× minSpacing,当线条间距等于基准大小时完全无法移动(移动后一侧小于最小尺寸)。尽管放宽到 0.85× 暂时缓解,但顺延逻辑本身的不稳定性使其难以调试。
尝试 16:先识格、再切图
先数清图片有多少行/列纯色块(countEqualWidthBlocks / countEqualHeightBlocks),然后用 cellW = ImgW ÷ Col 严格均分切图,不缩放、不插值。
方案要点 :扫描第一行/第一列统计颜色突变次数作为列数/行数(Col×Row);公式 cellW = ImgW ÷ Col 严格等分;众数取色(直方图统计取最高频颜色)。
问题:这个方案假设图片是"规整硬像素拼豆图"------每个色块是统一大小的纯色方块。但现实上传的图片往往是放大后的像素画,第一行/第一列可能不完整(图片有留白边框),导致行列计数错误(如 26×29 被数成 29×32)。
最终方案:多阶段网格检测流水线(当前架构)
综合以上所有尝试的经验教训,最终采用 量化→直方图→剔除→填充→传播→去重 的多阶段流水线。
关键设计决策:
-
"先量化再检测"而非"先检测再修正":尝试 10-15 的核心痛点是对原始像素做网格检测,受抗锯齿/噪点干扰严重。改为先 K-Means 量化减色(将成百上千种过渡色归并到 4-16 种纯色),再在量化图上检测网格线,大幅降低噪音。
-
"多阶段接力"而非"单函数闭环" :不再试图用一个迭代函数解决所有问题(尝试 10 的
detectGridByColorConsistency最终发展出过量的调整逻辑)。改为职责分离的设计------检测只管检测(直方图)、剔除只管剔除(离群检测)、填充只管填充(插值)、传播只管传播(BFS),每步可独立调试。 -
"色块提取独立于网格检测":网格线确定后,颜色提取用简单的众数取色 + 15% margin + 相似合并(曼哈顿色差 35),不再在颜色提取时回头调整网格线。
-
"背景剔除走射线而非泛洪":尝试期的 BFS 泛洪会把内部同色孔洞也剔除。改用四方向射线扫描,只有至少一个方向无障碍直达边界才判为背景,完美保留内部孔洞。
最终流水线:
ini
K-Means++ 颜色量化(K 自动 4-16)
→ 颜色跳变直方图检测(threshold=0.30, snapRadius=baseSize×0.3)
→ 离群线剔除(中位数间距 > 2.5× 截断, 偏差 > baseSize×0.4 剔除)
→ 等间距插值填充
→ BFS 锚点传播 + 局部搜索(最大 500 线)
→ 去重合并(间距 < baseSize×0.6)
→ 逐块众数取色(15% margin)
→ 连通分量标记合并(曼哈顿色差 < 35)
→ 四方向射线扫描背景剔除
→ CIE76 Lab 色差匹配色库
调色板系统
Mard (200+ 色)
按 A-H-M 系列编号:
- A 系列(A1-A26):米白/暖黄/肤色系
- B 系列(B1-B32):绿色系
- C 系列(C1-C32):蓝色系
- D 系列(D1-D16):紫色/深色调
- E 系列(E1-E26):粉色/红色系
- F 系列(F1-F16):橙色/暖红系
- G 系列(G1-G17):黄色/大地色
- H 系列(H1-H25):黑白/灰/透明,最常见系列
- M 系列(M1-M15):棕色/中性色
Artkal 优肯(100 色)
按 A01-A100 编号,覆盖从浅到深全色调。
导出格式
| 格式 | 用途 | 结构 |
|---|---|---|
| P2 短字符串 | 聊天分享 | P2:W×H:BRAND:色号列表:字母索引数据 |
| JSON | 存档编辑 | { version, width, height, brand, colors[], grid[][] } |
| CSV | 备料采购 | X,Y,Brand,ColorID,ColorName |
| URL 参数 | 在线互导 | ?w=32&h=48&brand=MARD&data=AAAA... |
P2 短字符串格式兼容国内主流拼豆工具(我家公主们爱拼豆、PixelBeads、beadgen 等)。