玩拼豆这几年,我花钱买图纸的次数一只手数得过来。倒不是舍不得那几块钱,而是网上买的图纸往往是通用模板,照片转出来的图要么清晰度不够,要么颜色跟手头豆子对不上。后来索性自己写了套工具,把手机照片直接转成拼豆图纸,核心就两个词:像素化和色板匹配。像素化负责把连续图像切成离散网格,色板匹配负责找出每个网格最接近的豆色色号。这套流程跑通之后,一张随手拍的照片,十几秒就能变成能照着拼的像素图纸。
这篇文章会把整套技术实践从原理到代码再到坑点完整记录下来。不管你是刚开始玩拼豆的新手,还是想扩展图纸来源的老玩家,又或者只是对图像颜色压缩算法本身感兴趣,这篇内容都能给你点实在的东西。工具全用Python实现,依赖只有Pillow和numpy,不花钱、不装大型软件,有手就能复现。
1. 项目概述与整体设计思路
1.1 为什么图片不能直接用来拼豆
先想清楚一个问题:一张照片为什么不能直接当拼豆图纸用?
一张手机照片最少也有1000×1000像素。而拼豆的原理是一颗豆就是一个像素,你要真按原图去拼,那得拼一百万颗豆子,这显然不现实。更关键的是,照片的颜色是连续渐变的,一张普通照片随便就包含几十万种颜色,而你手头能用的豆色顶多几十种。所以必须做两件事:把空间分辨率降下来(像素化),把颜色数量降下来(色板匹配)。
这块最容易被忽略的是“顺序”。我见过有人直接先把颜色匹配掉再缩放,结果是边缘一团糊。正确顺序一定是先降采样,再匹配色板。原因很简单:缩放本身就是一种信息丢弃,如果先匹配颜色,等于用原始分辨率做了一次决策,等缩小时这些决策又被打乱,反而产生更多错误色点。
小时候玩十字绣也是一个道理——图纸上每一个格子就是一种颜色的线,你得先把照片变成格子图,才能开始对着绣。拼豆跟十字绣在核心思路上完全一致,区别只是拼豆的网格更粗,颜色更少,所以对“压缩”的要求更高。
1.2 两条主线:像素化生成与色板匹配
整个项目可以拆成两条主线:
- 像素化生成:确定网格尺寸,把每个网格覆盖的原始图像区域压缩成一个代表色;
- 色板匹配:把这个代表色换算到最近似的豆色。
两个环节相互独立,却又互相影响。像素化决定细节保留程度,色板匹配决定最终色彩还原度。如果你的图纸拼出来像是复古游戏里的低分辨率角色,那多半是像素化环节太粗糙导致的;如果拼出来颜色整体发灰、发闷,那问题多半出在色板匹配的距离计算上。
拿我自己迭代过的几个版本来说。第一版只做像素化,直接用最近邻缩放,结果拼出来五官全糊,像隔着一层毛玻璃看人。第二版换了Lanczos插值,清晰度上来了,但颜色不对——豆色只有几十种,逐像素找最近色号的时候,绿色和黄色经常被算成近似色,拼出来草地像得了黄疸。直到我把色板匹配改成“先映射到HSL再算距离”之后,图纸才真正能看。
2. 像素化生成:图像降采样的原理与参数调优
2.1 缩放算法的选择:最近邻还是Lanczos
用Pillow做图像缩放,有多个插值选项:NEAREST、BILINEAR、BICUBIC、LANCZOS。对拼豆图纸这种离散像素网格来说,最稳妥的方案是Lanczos,因为它保留边缘细节的能力最强,缩小图片时不容易糊。
有人会用NEAREST,觉得既然要像素化那干脆最近邻采样得了。这个想法不对——NEAREST在放大时会产生锯齿,在缩小时会直接丢弃大量中间像素,很容易产生噪声点。比如一张照片里的头发丝,用最近邻缩小时可能恰好被跳过去,下一根头发的颜色就成了这个格子的代表色,拼出来就是一块突兀的色斑。
我实测下来的经验是:先用LANCZOS缩到目标网格大小,再对结果做一档轻微锐化。锐化能让相邻格子的颜色对比度稍微拉开,拼出来的轮廓更清晰。但注意别锐过头,否则噪点会爆炸。所谓“一档”,在Pillow里就是ImageFilter.SHARPEN滤镜用一次,别反复叠加。
这里补充一个概念:缩放的本质是重采样。假设原图是1080×1080像素,要缩到29×29,那么缩放后每个像素大约代表原图37×37像素的区域。缩放算法的工作就是决定这37×37像素的信息如何浓缩成1个像素的值。最近邻是从中间抽1个像素来代表,双线性是取周围4个像素加权平均,Lanczos则考虑了一个更大的采样窗口并对频域做逼近,相当于把“浓缩”这件事做得很讲究。
2.2 网格尺寸从哪里来:从拼豆板反推
网格尺寸不是随便定的,它跟你的拼豆板物理尺寸强相关。一个标准拼豆板是29×29格,拼出来成品大概是8厘米见方。如果你想拼更大的画,可以用29×29的板做多板拼接,或者直接上大规格专用板。
图纸的格子数必须和拼豆板一致,否则排豆的时候没法对齐。比如你用29×29的板,却生成了30×30的图纸,那最后一排豆子没地方放;反过来用28×28,又留出一列空白,拼出来边框歪了。
我一般做法是:先定成品尺寸,再反推网格数。比如想拼一块长宽比4:3的画,用29×29的板,那图像就得裁成4:3再缩到29×29。注意是裁剪而不是拉伸——拉伸会让脸型变形,人物照片尤其明显。裁剪时最好把主体居中,可以用Pillow的ImageOps.fit或者手动crop,保证中心构图。
想要大图的话,我的建议是先做网格拼接设计。比如用两个29×29板拼成58×29的画布,那生成的图纸就是58列29行。这种场景下,我通常把原图裁剪成对应长宽比后再整体缩放,而不是分块处理,因为分块会在拼接缝处产生颜色跳变。
2.3 采样策略:中心点采样与区域平均的取舍
缩放到目标网格之后,每个格子对应一个像素值,这就是目标颜色。但这种做法有个潜在问题:如果原图中某个区域纹理非常细碎,单次采样可能恰好抽到一条边缘线,导致颜色失真。
举例来说,一张照片里人的衣服是细条纹,看起来像是灰白相间的。直接采样时,某个格子如果正好落在白条纹上,取出来的就是白色;落在灰条纹上,取出来就是灰色。结果拼出来衣服的颜色是雪花状的,跟原图感觉完全不同。让每个格子内的所有像素做平均,再把这个平均值作为代表色,就相当于先对局部信息做了一次“投票”,花纹的影响被平摊了。
我后来在工具里加了一个混合模式:LANCZOS缩放的结果和区域平均值按7:3加权融合,出来的图纸稳定性好很多。这个比例是我反复试出来的,但你可以根据自己的图片微调。如果你图片纹理本来就少,纯LANCZOS结果就够了;如果纹理复杂,把区域平均的权重加到40%甚至50%都行。
区域平均的核心操作是先把原图像缩小到一个中间尺寸,再用box filter做一次均值滤波。Pillow里有一个很实用的小技巧:用Image.resize((w, h), Image.BOX)就能直接得到区域平均值,相当于把每个目标网格当成一个“桶”,把落在桶里的所有像素颜色倒在一起搅匀。
3. 色板匹配:颜色压缩背后的数学
3.1 色差计算公式:从RGB到感知均匀空间
RGB颜色空间是一个三维空间,每个颜色有R、G、B三个分量。两个颜色之间的相似度,最粗暴的计算方法是三维欧氏距离。比如红色(255,0,0)和暗红色(200,0,0)之间的距离是55。
但这里有个众所周知的坑:RGB空间不是感知均匀的。同样是数值差距50,从(255,0,0)到(205,0,0)的变化,人眼非常敏感;但从(10,10,200)到(10,10,250)的变化,人眼却几乎看不出差别。所以用纯RGB算距离,经常会把两个视觉上差异明显的颜色判成近似色,或者反过来。
我最早踩过这个坑。当时做一个蓝色天空的图纸,色板里有浅蓝、天蓝、深蓝三个色号,结果匹配出来全选了深蓝,因为深蓝的B分量更大,跟天空原本的B分量数值上更接近。但人眼看天空应该是偏浅蓝的。问题就出在RGB欧氏距离把亮度权重拉得太低,忽略了人眼对亮度的敏感度远高于对色相的敏感度。
改进方向有两条:一是把RGB转成HSL/HSV,在HSL空间加权重;二是直接用CIE Lab色空间,用DeltaE公式计算色差,更贴近人的感知。对拼豆场景来说,HSL加权已经够用,而且计算成本低。我在项目里用的是“RGB欧氏距离 + 亮度偏置”的折中方案——先保证亮度接近,再考虑色相差异,实测效果比纯RGB好很多。
具体来说,我给亮度差乘以1.5的权重,公式变成:
distance = sqrt(1.5 * (r1-r2)^2 + (g1-g2)^2 + (b1-b2)^2)注意这里不是给G加权重,而是给“亮度”加隐式权重。因为RGB三个分量对亮度都有贡献,简单做法是先把RGB转成灰度再乘系数,也可以直接转HSL取L分量做二次校验。
如果你想要更严谨,可以装colormath库,把RGB转成Lab后算标准的CIE76色差。这个公式对拼豆这种几十种颜色的场景非常够用,没必要上CIE94或者CIEDE2000,那些是针对高精度工业色差检测的,拼豆上属于杀鸡用牛刀。
3.2 把实体豆色变成可计算的数据
要把实体豆色变成可计算的数据,需要先做一次“测色”。最直白的做法是拿手机拍一颗豆,在照片里取中央区域的颜色平均值。但要注意避开反光——反光会让颜色偏白,测出来跟实际使用效果差异很大。我试过在台灯下拍和自然光下拍,同一颗豆测出来的RGB能差出20个色阶,这误差足以让匹配结果飘走。
更好的做法是去官网找色卡。很多拼豆品牌会公布标准色号对应的RGB值,专门供数字设计工具使用。把这些数据整理成一个字典,就是你的色板表。我建议把自己常买的品牌色号整理一份,并且每个色号拍一张实豆照片,跟官方RGB做对照——你会发现官方数据和实豆存在不小的偏差,因为塑料材质对光线的吸收反射和屏幕显示不完全一致。
整理色板时有个小技巧:同一个色号如果买过不同批次的豆子,颜色可能也会略有差异。我做了一张“品牌×色号”的对照表,把新旧两批豆子的实测RGB都记下来,匹配时取两个值的平均。这样虽然麻烦一点,但拼出来的成品颜色更稳定。
3.3 贪心匹配与全局优化的选择
有了色板表,剩下就是逐格子匹配了。最简单的是贪心:每个格子独立找出距离最近的色号。这个方案在小尺寸图纸上表现得不错,但在大图纸上容易产生“色彩抖动”——相邻格子为了追颜色,在两三个色号之间来回跳,视觉上看着花。
比如一块渐变色的区域,颜色从浅蓝慢慢变到深蓝。贪心匹配时,每个格子都去找自己的最近色号,结果可能是浅蓝、天蓝、浅蓝、深蓝这样跳来跳去,因为相邻格子原本的颜色差异很小,而色板里的色号间隔比这大得多,稍微有个噪声就会被分到不同色号。
改进方案是带约束的匹配:先逐格贪心选色,然后对相邻格子颜色差异做一次平滑,把频繁跳跃的格子改成邻域的主色。类似中值滤波的思路。我在工具里加了一个“防抖”开关,实现方式就是3×3窗口的中值滤波,把每个格子替换成周围9个格子中出现次数最多的色号。实测下来,图纸的整体还原度反而更高,因为跳跃被压掉了,大色块变得更干净。
如果你要做进阶优化,可以试试动态规划或者图割算法,把“相邻格子颜色应尽量相同”作为一个约束条件,跟“颜色匹配误差”放在同一个目标函数里优化。但说实话,对大多数拼豆场景来说,贪心+中值滤波已经足够,再复杂的优化算法性价比就不高了。
4. 从零跑通的完整实操过程
4.1 环境准备与依赖安装
整个项目只需要两个Python库:Pillow处理图像,numpy做矩阵运算。如果你已经装了Anaconda,这两个都是自带的。如果用的是纯净Python环境,打开终端执行:
pip install pillow numpy再加一个可选的scipy,主要用于中值滤波。基础版用Pillow的ImageFilter.MedianFilter也能实现同样的效果,就不需要额外装了。
我的建议是装scipy,因为拼豆图纸经常要跑几万次中值滤波的循环,scipy的ndimage.median_filter比Pillow滤镜快好几倍,尤其是处理大图纸时感受明显。
4.2 像素化与色板匹配核心代码
下面给出完整可运行的脚本。这段代码会把输入的图片转成拼豆图纸,并输出色号矩阵和配豆清单。
import numpy as np from PIL import Image, ImageFilter # 1. 定义色板:这里用代表性RGB值,实际请替换成你自己的豆色数据 PALETTE = { "WHITE": (255, 255, 255), "BLACK": (30, 30, 30), "RED": (200, 30, 30), "DARK_RED": (150, 20, 20), "ORANGE": (240, 140, 30), "YELLOW": (255, 220, 40), "GREEN": (40, 160, 60), "DARK_GREEN":(20, 100, 40), "SKY_BLUE": (100, 180, 230), "BLUE": (40, 80, 200), "DARK_BLUE": (20, 40, 120), "PURPLE": (120, 50, 160), "PINK": (240, 150, 170), "BROWN": (120, 70, 30), "GRAY": (128, 128, 128), } def pixelate(img_path, grid_w, grid_h, blend_ratio=0.7): """像素化:先Lanczos缩放,再与区域平均按比例混合""" img = Image.open(img_path).convert("RGB") # 裁剪到目标长宽比 target_ratio = grid_w / grid_h w, h = img.size if w / h > target_ratio: new_w = int(h * target_ratio) x0 = (w - new_w) // 2 img = img.crop((x0, 0, x0 + new_w, h)) else: new_h = int(w / target_ratio) y0 = (h - new_h) // 2 img = img.crop((0, y0, w, y0 + new_h)) # 方式一:Lanczos缩放 lanczos_img = img.resize((grid_w, grid_h), Image.LANCZOS) # 方式二:区域平均 box_img = img.resize((grid_w, grid_h), Image.BOX) # 混合 blended = Image.blend(lanczos_img, box_img, 1 - blend_ratio) # 轻微锐化 blended = blended.filter(ImageFilter.SHARPEN) return blended def match_to_palette(img, palette): """色板匹配:RGB欧氏距离 + 亮度偏置""" arr = np.array(img).astype(int) palette_arr = np.array(list(palette.values())).astype(int) names = list(palette.keys()) h, w, _ = arr.shape result = np.zeros((h, w), dtype=int) # 展平成一维,方便向量化计算 flat = arr.reshape(-1, 3) dists = np.sqrt(1.5 * (flat[:, 0][:, None] - palette_arr[:, 0][None, :])**2 + (flat[:, 1][:, None] - palette_arr[:, 1][None, :])**2 + (flat[:, 2][:, None] - palette_arr[:, 2][None, :])**2) best_idx = np.argmin(dists, axis=1) return best_idx.reshape(h, w), names def dedupe_by_median(matrix, names, kernel_size=3): """中值滤波防抖:把频繁跳跃的格子替换为邻域主色""" idx_map = {name: i for i, name in enumerate(names)} num = len(names) coded = np.zeros(matrix.shape, dtype=int) for i, name in enumerate(names): coded[matrix == i] = i # 用scipy做中值滤波(这里用Pillow的替代方案避免额外依赖) from PIL import ImageFilter # 将索引矩阵转成图像后做中值滤波 img = Image.fromarray(coded.astype(np.uint8)) filtered = img.filter(ImageFilter.MedianFilter(size=kernel_size)) return np.array(filtered) def count_beads(matrix, names): """统计每个色号用量""" counts = {} for i, name in enumerate(names): counts[name] = int((matrix == i).sum()) return counts # 主流程示例 if __name__ == "__main__": GRID_W, GRID_H = 29, 29 # 标准拼豆板 img_pixelated = pixelate("input.jpg", GRID_W, GRID_H, blend_ratio=0.7) idx_matrix, name_list = match_to_palette(img_pixelated, PALETTE) # 防抖处理 idx_matrix = dedupe_by_median(idx_matrix, name_list, kernel_size=3) # 输出色号矩阵 for row in idx_matrix: print(" ".join(name_list[i][0] for i in row)) # 输出配豆统计 beads = count_beads(idx_matrix, name_list) for name, cnt in sorted(beads.items(), key=lambda x: -x[1]): print(f"{name}: {cnt} 颗")这段代码每一步都加了注释,你可以直接跑。输入图片名改成你自己的文件路径就行。它输出的色号矩阵是一串首字母缩写,方便对照色板名称;配豆统计是采购清单,直接按数量买豆。
4.3 生成图纸与配豆清单
跑完上面的脚本,你会得到两个结果:一个是每行的色号首字母缩写矩阵,一个是每个色号的用量统计。但我强烈建议再做一步:把矩阵渲染成带网格线和色块的可视化图纸,方便看着颜色排豆。
可视化图纸的做法很简单——创建一个网格大小的图像,每个格子填充对应色号的RGB值,再用灰色细线画出网格线。如果想打印出来照着拼,最好生成PDF格式,每个格子足够大,色号标注也看得清。
我自己的工具还会额外生成一张“配豆清单”,按色号使用量从高到低排列。这里有个经验:统计用量之后,建议每种色号再多买5%到10%的余量。拼豆的时候总会有损耗,有时候掉在地上不见了,有时候熨烫时融歪了,多备一点总比中途缺色号强。
输出图纸时,我还会做一个对称性检查。如果原图本身是对称的(比如建筑、花朵),拼豆图纸也应该保持对称。这个检查其实很简单——把色号矩阵左右翻转后跟原矩阵对比,如果不一致,说明某处颜色匹配出了问题,可以针对性修正。
5. 常见问题与排查技巧实录
5.1 图纸发糊、边缘毛糙怎么办
最常见的问题是图纸拼出来像蒙了一层雾,颜色不够跳。原因一般出在像素化环节:Lanczos缩放之后没有锐化,或者混合比例中区域平均权重太大,导致相邻格子的颜色差异被抹平。
解决思路是调blend_ratio。这个参数控制Lanczos结果和区域平均的混合比例。数值越接近1,越强调边缘清晰度;数值越接近0,越强调颜色稳定。我一般从0.7起步,如果边缘毛糙就往0.85调,如果出现噪点就往0.6调。
另外检查一下输入图片本身的分辨率。如果你用一张200×200的小图去做29×29的图纸,每个格子只代表大约7×7的像素,信息量本来就不够,再怎么调参数也救不回来。这种时候不如换一张分辨率更高的原图,或者用图像超分辨率模型先放大再处理。
5.2 大面积纯色区域出现杂色点
拼天空、拼背景墙时经常遇到这种情况:明明是一整片蓝色,图纸里却点缀了好几个白色或深蓝色点。这是因为原图在纯色区域也不是完全均匀的,可能有轻微的噪点或者压缩痕迹,导致局部颜色偏离了中心值。
解决手段就是我前面提到的中值滤波。3×3窗口通常就够用,窗口再大就会过度平滑,把细节全吃掉。如果你用了中值滤波还是有问题,检查一下色板里是否有和中心色非常接近的色号——有时候是因为目标色恰好处在两个色号的正中间,匹配结果随机偏向其中一个,这种情况把两个色号之一的RGB稍微调整一下,让人为区分度变大就好。
另外,原图的压缩痕迹会在纯色区域留下色块状噪声,这种用区域平均采样比纯Lanczos更有效,因为平均操作会把这些压缩噪声“洗掉”。如果你用的是手机照片,微信/QQ传图时尽量选“原图”,避免二次压缩。
5.3 照片明暗对比过强导致颜色发闷
最后说一个比较玄学的问题。有时候原图看着很好看,转成拼豆图纸却整体发灰、发闷。这不是算法问题,而是屏幕显示和实际拼豆效果之间的差异造成的。
屏幕上看到的颜色是自发光的RGB模式,而拼豆是反射光的物理色料,在自然光下看起来会比屏幕暗得多。所以如果原图本身偏暗或对比度过强,拼出来的效果往往更暗。我的建议是在像素化之前,先对原图做一次适度的亮度和对比度调整。具体参数因图而异,一般把亮度提5%到10%,对比度降低5%左右,拼出来的效果更容易接近屏幕预览。
还有一个容易被忽略的细节:色板匹配时要不要考虑豆子的“拼接缝”。两颗豆拼在一起,缝隙会让整体颜色看起来比单颗豆略深。所以如果你拼大色块,匹配结果往往会偏浅,实际拼出来反而刚刚好。这个没法在算法里完全修正,只能靠实际拼几次积累经验。
5.4 参数调整速查表
我把常遇到的参数问题和调整方向整理成了一张表:
| 症状 | 调哪个参数 | 怎么调 |
|---|---|---|
| 边缘毛糙,轮廓不清晰 | blend_ratio | 从0.7往0.85调 |
| 出现杂色噪点 | 中值滤波窗口 | 从3×3调到5×5 |
| 颜色整体发灰 | 原图亮度 | 提高5%-10% |
| 大色块颜色不均 | 混合模式中BOX占比 | 增加区域平均权重 |
| 相邻格子色号跳动 | 中值滤波 | 确保已开启防抖 |
| 主体被裁掉 | 裁剪逻辑 | 检查target_ratio是否匹配 |
这份表是我迭代了三十多张图纸后总结出来的,可以说浓缩了所有踩过的坑。你拿着它去调,基本能解决九成问题。
在实际操作中,我最深的体会是:**像素化做得糙,色板匹配再准也没用;色板匹配得糙,像素化再精细也白搭。**这两个环节像齿轮一样咬合在一起,必须同时处理好,出来的图纸才是真正能用的。我后来还给自己写了一个小功能——从生成的图纸直接反推“预估成品效果”,在屏幕上把色号矩阵放大渲染成像素画,拼之前就能看到大概样子。这个小功能救了我好几次,避免了我浪费几百颗豆子拼出一张没法看的图。
如果你也动手实现了这套流程,建议从一张简单的单色背景照片开始,拼一块29×29的小图纸练手。等你把像素化和色板匹配的参数都摸透了,再去挑战复杂场景。这个项目后续还可以往很多方向扩展,比如给图纸加数字编号、把配色结果导成Excel采购单、甚至用城市照片生成像素风景图。但基础就这五个章节的内容,吃透了,剩下的路你自己就能走出来。