我先前的个人主页,是用简单的 SVG 代码一个个画出来的。因为是简单的图形,所以画一个出来没什么问题,但要是画很多的话…… 最终我决定,这种做法太累人了,乖乖手绘吧。

不过点进来之前,我需要事先说明下:这不是一篇教学文章!


现在你可以访问 我的个人主页 看到首页的风格变了。目前绘制完毕且托管的只有首页和电话(我很喜欢电话的设计),我花了很多时间来将图片转换成 SVG 代码 —— 是的,这不是一张图片,而是 SVG 代码。

通常不靠手搓(也没人会这么做吧!)画出复杂矢量图形的方式有可视化矢量图形编辑器,比方说 Adobe Illustrator,也是我高中学习使用的。可惜现在毕业、根本没有免费的 Adobe Illustrator 供我使用,我也早就忘了如何使用这类软件,所以可视化矢量图形编辑器只能被我残忍 pass 掉。

至于矢量图形、SVG 代码是什么 —— 如果你抱有这样的问题的话,那说明你还没有读过这篇文章的 前文 吧!快去看看吧~

我的方案是直接在 Procreate 这类绘画软件上画完整张图,然后用 Potrace 将位图图形转换成矢量图形 —— 我太聪明了!快来实现吧!

原理一句话能说完,但凡事都是说起来简单做起来难啊。我和 agent 进行了很多研究和探索,因为我对图形文件的理解还是不到位的,总之最后还是成功地将画转换成了可互动的 SVG 代码。下面我会简单说说我的方案。

工具选型

在深入流水线之前,先介绍三个主角。整条管线是三个工具协作完成的,它们各自的定位值得先说清楚。

一、ImageMagick。

ImageMagick 是一套命令行图像处理套件,也是 Unix 世界最全能的光栅图处理工具。它的核心机制是 magick,支持几乎所有你能想到的位图操作:格式转换、裁剪、旋转、颜色量化、阈值化、合成模式、滤镜、通道操作、巴啦巴啦…… 而且都能在一条命令里用管道式参数链接起来,不需要读文件、写中间产物再读回去。

这个流水线里几乎所有「位图到位图」的操作都交给 ImageMagick 来做。替代品可以是 Python 的 PIL / Pillow,或者 OpenCV,但是一条 magick 命令能干的活,真的要选择去写十几行代码吗?

二、Potrace。

Potrace 是位图矢量化工具,也是 FOSS 世界里事实上的标准。Inkscape 的 Trace Bitmap 功能底层用的就是它,很多商业软件也把它当作矢量化引擎。

它的定位很清晰:接受一张二值位图(只有黑白两色,连灰色都没有),输出一个 SVG、EPS,或者 PDF 文件,里面则是用贝塞尔曲线描述的路径。

算法管线分为三步:先找出所有黑白边界的像素链,再把像素台阶拟合为多边形,最后把多边形顶点转成三次贝塞尔曲线。

三、Python。

脚本最终我决定使用 Python 来写。用其他语言写也完全没问题啦,只是我对 Python 更熟悉一些(JavaScript 是什么东西,不熟)。

作画与导出

为了防止有读者听不明白,我决定讲讲电子作画时的基础概念和知识 —— 不过真的会有不懂的人来看这篇文章么?总之先讲 图层 吧!图层是一张张透明的画布叠在一起,每层画不同的东西,最终看到的画面是从上往下看穿所有图层后的视觉合成。每一层都可以单独修改、隐藏、换顺序而不影响其他层 —— 这自然给作画带来了巨大的灵活性。更关键的是,图层是有顺序的:上面的层会盖住下面的层。

我的方案严重依赖图层这个概念。每个物品都有自己的图层,例如大门是一个图层,城墙是在这之下的另一个图层。这一步是有讲究的:整条管线的每一次追踪都是「单图层对单掩膜」,如果你把所有元素合成到一张图再导出,后面就没办法单独给城堡添加悬停效果,或者让某一棵树动起来。

接着需要将图层以 PNG n 的格式一一导出,千万不要把背景图层导出来。我把它们全部丢进一个 transparent/ 目录,命名成 layer-01.png…… 数字就是图层的叠放顺序,01 在最底层,例如背景的天空,数字大的则在上层。

这里还得解释一个概念:PNG 的透明度是怎么存的?每张 PNG 实际是四通道数据:R(红)、G(绿)、B(蓝)、A(alpha,即不透明度)。RGB 三个通道大家想必耳熟能详了,是对颜色的描述,而 alpha 描述的是这个像素有多透明:0 是完全透明,255 是完全不透明,中间值自然便是半透明了。

用电子软件作画时,我们时常会看到笔刷的边缘有着 羽化。它就是 alpha 从 255 平滑衰减到 0 所形成的软边,看起来让我们的笔刷柔和上去不少。

单层处理流水线

每张 PNG 都要过一遍以下流程才能变成一组 <g> 元素:

  1. 检查是否是空图层。

    我用 ImageMagick 把每个图层的 alpha 通道单独提取成灰度图,然后计算平均的亮度:magick layer.png -alpha extract -format "%[fx:mean]" info:-

    %[fx:mean] 是 ImageMagick 内置的像素表达式引擎,info:- 让结果走 stdout。

    返回值是 0.0 到 1.0 之间的浮点数。用我实际的数据来说:天空层约 0.48,即占画面一半都是不透明的像素;白色小鸟的那层约 0.003,小小的;空白的图层为精确的 0.0。如果 alpha 平均值小于 0.0001,即几乎全透明,那这个图层就是空图层,要被跳过。

  2. 硬阈值化 alpha 值:magick layer.png -alpha extract -threshold 50% alpha.bmp

    阈值化 是把连续灰度映射成二值的操作。你会选择一条分界线,高于它的像素变白、低于它的变黑。阈值化的产物叫作二值图,是图像处理里最简洁的形式,也是很多算法的强制输入要求,包括我们马上要用的 Potrace。

    回到命令,我用 -threshold 参数把所有大于等于 50% 亮度的像素变成纯白,其余变成纯黑。然后输出成 BMP3 格式,也是 Potrace 唯一无损保证能读的格式。

    这么做的原因在于 Procreate 的笔刷有抗锯齿羽化边,边缘像素的 alpha 值从 255 平滑衰减到 0,可能中间横跨了 5 到 10 个像素的宽度。这些半透明的像素在后续合成中不能说没有用,只能说用处太大了,把我合成出来的东西全都搞砸了!

    具体会怎么「搞砸」呢?后面要给每个颜色造掩膜时,会用到 alpha 去裁剪真实的形状。如果 alpha 是羽化带,乘起来得到灰度 76 的中间值,Potrace 看到灰度就会把它当成实心区域来追踪。结果是每一个色块外围都会被绕出一圈轮廓,不仅翻倍了文件体积,还在视觉上糊成一坨坨。

    所以需要提前将它们合并。50% 是我认为较为舒服的值,实际上你可以随意调整。阈值化会让边缘变有锯齿状,但后续 Potrace 的贝塞尔平滑会把台阶拟合回曲线,反而比羽化边更干净!

  3. 量化颜色,即把连续色阶压缩成 N 色:magick layer.png [-blur 0xR] -colors N +dither quantized.png

    量化 的意思是把一个连续范围压缩成若干个离散的层级。在颜色量化里,我们把原图里成千上万种颜色减到 N 种,每个像素替换成 N 色里距离最近的那个。这是有损操作,但对我们的矢量化来说恰恰是必要的。因为只要把颜色阶梯化,我们才能划分出明确的色块边界。假设原图是一片渐变的云,量化会把它切成几个色阶带,每个色阶带才能被 Potrace 追成一条边界清晰的 path。

    我用 ImageMagick 的 median-cut 量化器,按照 RGB 颜色空间里最长的轴切分像素的分布,直到得到 N 个聚类。

    我建议是每个图层都手动调整下 N 的值,因为自动估算对艺术风格并不敏感,需要人类的介入、指出一个图层里到底该有多少种颜色。我的部分配置表:

    1
    2
    3
    4
    5
    6
    7
    8
    LAYER_COLORS = {
    1: 1, # 纯色的天空
    2: 4, # 月亮 + 星星
    3: 15, # 带有窗户的塔顶
    8: 6, # 树
    12: 10, # 上半墙
    20: 15, # 城门
    }

    纯色图层给 1 就足够了。复杂图层能给到 15,让它能同时容纳石头灰阶和橙色光斑。

    另外,误差扩散抖动得关掉(+dither 里的 + 是 ImageMagick 约定的「关闭」前缀,对标 -dither。怎么感觉应该反过来哩?),否则两种邻近颜色的像素散点会多出第三种颜色。在位图显示上,它是好看的,但对矢量化是毁灭性的 —— 每个散点都会变成一种独立的 <path>,SVG 体积又更上一层楼,视觉上还花花的。

    可选的 -blur 0xR 是对笔刷纹理特别重的层先做高斯模糊,把颗粒糊掉再量化。我只对城门那层用了它。

  4. 列出实际用到的颜色并去重:magick quantized.png -alpha off -unique-colors txt:-

    量化时限制了图层中最多出现 N 种颜色,但如果原图的色阶数小于 N,那实际用到的会更少。我们要的是真正出现了的颜色,必须挨家挨户造掩膜追踪。

    -unique-colors 会生成一张 1×N 的像素图,每个像素是一种颜色。txt:- 把它以 IM 的文本格式 dump 到 stdout,长这样:

    1
    2
    3
    4
    0,0: (28,29,48)  #1C1D30 srgb(28,29,48)                                  
    1,0: (18,18,36) #121224 srgb(18,18,36)
    2,0: (132,70,11) #84460B srgb(132,70,11)
    ...

    用正则 #([0-9A-Fa-f]{6}) 把十六进制色值抠出来。

    接着还需要合并一次,因为笔刷纹理可能产出视觉上一样但数值差 1 到 3 个颜色对(比如 #47475B#47475C),会在后续的转换中产出很多几乎同色的 <g> 颜色,白白浪费浏览器的渲染资源。

    合并策略很简单:如果两个颜色的 R、G、B 每个通道差值都小于某个阈值(我给城门设的是 15),判定为重复,只保留第一个。

  5. 为每种颜色构造二值掩膜。一共是两条命令链。

    掩膜 是图像处理里的万用工具!简单来说,它是一张告诉后续操作哪些像素该保留、哪些该忽略的黑白图。典型的约定是白色等于保留,黑色等于忽略,不过有的工具会反过来,所以用任何工具之前都要先看文档。你可以把掩膜想象成是剪纸。把这张剪纸叠在原图上,黑的地方剪掉、白的地方留着。上色的时候很有用喔!

    首先是要生成目标色和其余色的掩膜:magick quantized.png -alpha off -fuzz 3% -fill white -opaque "#COLOR" -fill black +opaque white BMP3:raw.bmp

    拆解下来是:-fuzz 3%-opaque 设一个 3% 的颜色匹配容差(因为量化 + BMP 往返可能让颜色偏差 1-2 单位,精确匹配会漏掉边缘像素),-fill white -opaque "#COLOR" 把所有接近目标色的像素刷成白色,-fill black +opaque white 再把所有「不是白色」(+opaque-opaque 的反操作)的像素刷成黑色。

    先前的 -threshold 仅是按照亮度切分,无法精确匹配 RGB 值,使用 -opaque 可以正确的按照颜色切分。

    接着是要用 alpha 把掩膜裁回真实形状:magick raw.bmp alpha.bmp -compose Mulitply -composite -negate BMP3:final.bmp

    这里涉及到 合成模式 的概念。合成模式决定了两张图怎么叠在一起。最常见的是 Normal,就是上层完全覆盖下层啦。还有:

    • Multiply:两张图对应像素值相乘再除以最大值;
    • Lighten:对应像素取较亮值;
    • Screen、Overlay、Difference…… 因为本篇不会用到,所以就不过多讲解啦。

    上一个命令链的问题在于,原本透明但颜色恰好匹配背景默认值的像素也可能变成白色,所以要用 alpha 掩膜做交集、把「透明但被误标白色」的区域切掉。Multiply 在二值图上等价于布尔 AND:255 乘以 255 等于 255(白),255 乘以 0 等于 0(黑),0 乘以任何等于 0。

    不过我们这里是把白色弄成了目标色,和 Potrace 把白色当成要被忽略的背景色的约定不同,需要用 -negate 反转一下。最终 final.bmp 里目标色区域是黑的,其余是白的 —— 这才是 Potrace 想要的输入。

  6. Potrace 追踪:potrace final.bmp -s --flat -t TURDSIZE -o path-COLOR.svg

    终于到我们的 Potrace 出场了。

    三个参数:-s 输出 SVG(否则默认是 EPS),--flat 输出扁平的 <g> 而不是嵌套结构,-t 10(turdsize)丢弃包围像素数小于 10 的孤立斑点。turdsize 可以用来降噪。

    输出长这样:

    1
    2
    3
    4
    5
    <svg ... viewBox="0 0 238.8 149.3">
    <g transform="translate(0,1493) scale(0.1,-0.1)" fill="#000000" stroke="none">
    <path d="M21483 14869 c-191 -100 -354 -221 -431 ..." />
    </g>
    </svg>

    那个诡异的 translate(0,1493) scale(0.1,-0.1) 变换乍一看像 bug,其实是 Potrace 的签名:它内部用 10 倍定点整数坐标做贝塞尔计算(提升精度、避免浮点误差累积),所以 path 里的数值都是 ×10 的;同时它使用数学坐标系 Y 轴朝上,而 SVG Y 轴朝下。所以 scale(0.1, -0.1) 会做两件事:一、除以 10 还原坐标、Y 方向翻转;二、translate(0, 1493) 把翻转后跑到负象限的图形平移回可见区域(1493 是图像高度)。

  7. 抽取 <g>,塞入正确的 fill 颜色。

    我们已经得到了轮廓,接下来只需要把第 4 步的真实颜色放到 <g> 里就好了!Potrace 默认输出 fill="#000000",得替换掉。

    建议使用正则来做:<g transform="([^"]*)"[^>]*>(.*?)</g>。抓到 transform 的值和内部 <path> 内容,重新拼装成 <g transform="..." fill="#COLOR" stroke="none">...</g>

对比度方案

有一些复杂的图层,例如在暗色的背景上有着更暗的细节,像砖块和城墙的颜色。如果按照全图颜色分布切分的话,砖块会消失的。解决方案是完全绕开全局量化,改用两次独立的阈值化跟 Potrace。我下面简称该方案为对比度方案。

详细对比的话,对比度方案的流程需要通过局部亮度的阈值来决定哪些像素是同一块。如果图层里有细节只占 10 灰阶跨度,而全图跨度有 250 灰阶,那么细节在原先的量化器看来是噪音、可以被合并进主体色。而局部亮度的方案中,小差异就可以被放大成可切分的对比。

至于为什么是两次阈值化,因为一次需要追整块形状,一次追纹理细节即可。对比度方案的本质是让图层变成只有两个亮度群的画面,即「主体」和「细节」。再多分几次没有意义,反而可能将笔刷的噪点切出来。砖墙就是「墙」和「砖」两个元素,不需要再增加。

不过该方案需要手动填写图层需要什么颜色,因为它终究只是产出黑白两色的二值掩膜。这一步可以用 ImageMagick 从原图采样平均色,可能产生误差,所以建议是用吸色器直接吸、手动填写。我的配置表长这样:

1
2
3
4
5
6
7
8
# (基础色, 细节色, 阈值%, 是否反转)             
CONTRAST_LAYERS = {
9: ("47475C", "030205", 50, False), # 鸟:身体 + 黑眼
12: ("1C1D30", "121224", 30, False), # 上半墙:底 + 砖缝
17: ("24273D", "0F1021", 60, False), # 左箭头:底 + 暗点
19: ("18182E", "0F1023", 80, False), # 下半墙:底 + 砖缝
22: ("0D0F1E", "1E2035", 40, True), # 电话亭:暗体 + 亮屏
}

整条对比度流程的关键命令是: magick layer.png -alpha off -colorspace Gray -auto-level -threshold T% BMP3:thresh.bmp

四个操作逐一解释:

  • -alpha off 忽略透明通道,否则透明像素会干扰后面 -auto-level 找最小最大值;
  • -colorspace Gray 用 ITU - R BT.709 加权公式把 RGB 转成单通道灰度:=Y = 0.2126・R + 0.7152・G + 0.0722・B=。之所以加权是因为人眼对绿色最敏感,对蓝色最不敏感。对比度方案关心的是明暗差异不是色相差异,转灰度把我们关心的维度独立出来;
  • -auto-level 是整个方案的「魔法」。它找到当前图像的最小灰度 vmin 和最大灰度 vmax,对每个像素执行 =new = (old - vmin) / (vmax - vmin) × 255=,即把现有灰度范围线性拉伸到 [0, 255]。举个例子:上半墙层原始灰度分布是 [5, 55],跨度 50。auto-level 之后变成 [0, 255],跨度 255。墙和砖块原本相差 10 灰阶,拉伸后相差 51 灰阶,已经足够用阈值干净切开;
  • -threshold T% 把灰度 ≥ T 的变白,< T 的变黑。T 的选值要根据该层 auto-level 后的灰度分布手调。我的上半墙选 30%(刚好只切出最暗的砖块),下半墙选 80%(这层纹理更模糊,要把阈值线放更高才能吃到所有砖块),电话亭选 40%。

阈值化后如果该层是 invert True=,还要追加一个 -negate。电话亭是特殊情况 —— 它的细节(屏幕、按键、听筒)比底色更亮而不是更暗。默认的「暗变黑」规则只会把底色抠出来,但底色已经追过一次了;-negate 反转灰度后,原本亮的像素变暗、被阈值判为黑(前景),Potrace 追的就是细节而不是底色。

接着要用 alpha 把透明区强制变成白色,防止透明区被误认为前景: magick thresh.bmp ( alpha.bmp -negate ) -compose Lighten -composite BMP3:detail_mask.bmp

这里用 Lighten compose 而不是 Multiply。Lighten 的语义是对每个像素取两张图的较亮值。两个输入:thresh.bmp(细节二值化结果,前景是黑)和 alpha.bmp 取反(原不透明区 = 黑,原透明区 = 白)。四种情形:

原 alpha threshold 结果 alpha 取反 Lighten 结果 含义
不透明 黑(细节) 细节保留
不透明 白(非细节) 正确忽略
透明 黑(误判) 透明区救回来
透明 正确忽略

第三行是关键 —— 不管阈值怎么错判,只要原来是透明,白色就压过去了。换成 Multiply 效果会完全相反 —— 不透明区会全部被乘成黑,灾难。原本方案的 Multiply 和对比度方案的 Lighten 在数学上是 de Morgan 律的对偶,不是随便选的,取决于中间数据里「黑白哪个代表有效」。

剩下的步骤和原本方案一样:Potrace 追踪、抽取 <g> 、塞上配置里写的十六进制色。

对比度方案每层会产出最多两个 <g> —— 一个基础形状(从 alpha 掩膜追来)、一个细节形状。比原本方案 N 个 <g> 要少得多,这也符合「这一层概念上就两个元素」的直觉。

混合方案

而更为复杂的图层中,例如上半城堡墙既有暗底和砖块,又有高饱和的橘色窗户,只是量化的话砖块会消失,走上一段提到的对比度追踪又会弄丢窗户,因为窗户灰度落在阈值之上,会被判成背景。

解法是两条路都跑,然后把产物叠起来。我把这两个麻烦的图层(上半墙、下半墙)标记成 HYBRID

1
2
3
4
5
6
7
HYBRID_LAYERS = {12, 19}

if layer_num in HYBRID_LAYERS:
groups = process_layer(layer, ...) # 正常量化
groups.extend(
process_layer_contrast(layer, ..., detail_only=True) # 对比度
)

detail_only True= 是关键。正常的对比度方案会先从 alpha 追一次基础形状;但在混合方案里,正常量化那边已经把底色当作最大占比色给追过了,如果对比度再追一次,两个完全重叠的同色 <g> 会造成 over-draw 和抗锯齿边缘错位闪烁。detail_only 让对比度方案跳过基础形状追踪,只贡献细节层。

最后 <g> 列表的顺序是:

  1. 量化产物的底色 <g> (整层轮廓)
  2. 量化产物的橙窗、亮边等 <g> (点缀色块)
  3. 对比度产物的砖块 <g> (纹理)

SVG 渲染是 画家算法,后写的盖在前面。叠出来的视觉效果是:深紫蓝的墙打底,上面点缀橙色窗户,最上面叠一层更深的砖块纹理,正好是原画的层次。如果把对比度放在量化之前 extend,砖块会被底色完全盖掉。

最后组装

每层处理完都会返回一个 <g> 列表。主循环把所有图层的产物按图层编号顺序拼接成一个大列表,然后套一个 <svg> 外壳:

1
2
3
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="{viewbox}" width="800" height="500">
{"".join(all_groups)}
</svg>'''

viewBox 从任意一次 Potrace 输出里抓出来(所有层尺寸相同,共用一个)。widthheight 是默认显示尺寸,CSS 可以覆盖。

图层编号顺序就是叠放顺序:layer-01(天空)产出的 <g> 在列表最前,画在最底;layer-22(电话亭)产出的 <g> 在最后,画在最顶部。

最终 SVG 我直接内联粘贴进 index.html,而不是用 <img src="castle.svg">。外部 SVG 没法被外部 CSS 样式化(比如给某层加悬停颜色),也没法被 JavaScript 操作里面的 <g> —— 内联之后就随便了。是不是很有意思呢?

之后

个人主页的重构会花费一些时间,因为现在我可以手绘、用更简单和省力的方式在网页里添加元素,例如首页中的城堡我参考了《喜羊羊与灰太狼》中「狼堡」 的设计(好啦根本一模一样)。我后续决定添加更多有趣的内容进去,比方说下一个我打算重绘的 /death 页面,就会出现一些游戏元素,像《植物大战僵尸》啦。

再就是个人主页在不同设备上的显示。我希望所有设备都能访问个人主页,展示的不同没有关系。我发现部分博友会在浏览时关闭 JavaScript,而我主页上有些页面使用了 JavaScript。我会尽我所能将 JavaScript 从主页上剔除,除非是必须使用 JavaScript 的功能,那我会想办法将内容以别的形式展示出来。SVG 以及更新的 CSS 语法同理 —— 总之不要出现那种「不开启 xx 功能或者没有 xx 就不能看」的问题吧!

虽然话是这么说,但其实也想过在主页上添加解密要素。不使用 JavaScript 的话,应该会滥用 LocalStorage 吧……?到时候再说,到时候再说。