Home 制作一张匹配形状的字符画
Post
Cancel

制作一张匹配形状的字符画

最近玩字符画,查了一下主流的字符画算法,基本采用平均灰度这一指标将图片区域映射到单个字符。

他们的具体做法是,建立灰度级别到字符的查找表,类似[0~255]->[0-9a-zA-Z!@#$%^&*()],然后做一些位置对齐、直方图均衡等额外操作使得成图更加美观。

以上实现具体可参考知乎:字符画——从入门到不屑

这样的实现直观而简洁,但是平均灰度这一指标是不完美的。请见下图:

图1

图2

图中“好”字的上沿出现了一行独立的单引号’,“一”字的上沿变成了纵向延伸而横线断裂的左括号(,这些类似的误差导致这张字符画的观感较差。

事实上,基于平均灰度的字符画生成更像是一种降采样+添加噪声的图像处理:算法按像素区域将灰度转化为字符,而字符形状的不匹配相当于引入了一种结构化的随机噪声。

这个算法是不完美的,那么有没有完美的算法呢?本文就来探究这件事。

图像-字符形状匹配原理

在字符画生成算法中,受应用限制,我们一般预先设定好字符的种类、样式、大小,然后把字符堆起来,希望它尽可能地还原原画。例如一个3x3字符大小的“工”字图像,使用字符{1-+}可以拼成下面字符画:

1
2
3
-+-
 1
-+-

从图像像素层面考虑,一般一个字符位在字符画图像中所占的区域是固定的,该区域包含若干个像素。

那么对于这块区域来说,为达成尽可能还原原画的目标,就应当尽量让每个像素都与原图一致

当然,如果每个位置的像素都完全一致,那么字符画和原图就一模一样——但这件事很少发生。100个字符只能对应100个区域图形,而8x16的像素区域即有$256^{8*16}$种可能。

因此我们需要一个损失函数来指导字符匹配。它需要衡量每个字符与原图的一致性,从而使得每个区域的字符能最佳表达原图区域。

一致性和表现力这些概念对我来说比较复杂,这里暂时选用常用的像素均方误差来衡量字符一致性,即$J=\Sigma_i\Sigma_j{(I_{ij}-\hat{I}_{ij})^2}$

有了这样一个损失函数以后,我们就可以为每块区域给出最优的字符,从而组成整幅字符画。

图像-字符形状匹配实现

整个实现主要分为两个部分:

字符图像预生成

字符数量少而且图像是固定的,如果每次生成字符画都计算字符图像会带来较大的重复计算开销,所以可以预生成并存储字符的灰度图像。

为计算损失函数,这里将图像转为科学计算库numpy存储。 中间操作采用numpy进行以加速计算,在最后生成图像前从numpy转换到PIL.Image。

为了排版工整和计算方便,需要裁剪字符图片使它们等宽。 目前有一些等宽字体,但主流使用的字体并不严格等宽。 这里我简单地将较大的字符图片裁剪到最小字符的大小(本来宽度只差1个像素)。

最后将字符图像数组stack为维度(num, h, w)的数组,便于批量计算

字符画生成

按原图像字符区域计算损失函数,然后把最优字符的图像贴上去。

实现效果如下:

图3

图4

图5

可以看到,生成字符画的字形轮廓已经非常好了,手的轮廓和猫的翘毛非常还原。不过黑猫的面部太模糊了…

额外改进

直方图均衡

我认为字符的表达力比黑白画要弱,需要进一步对原图像额外做一些艺术性处理,比如直方图均衡。 改进效果如下:

图6

可以看到黑猫的眼睛和蝴蝶结亮了,而手的轮廓相对没有以前清楚了。

直方图均衡不是一个稳定的改进方案,它会带来一些额外的问题:

  1. 不同帧的同一物体可能被映射到不同灰度,导致动画观感下降。可以选取某一关键帧构建全局的灰度映射;
  2. 字符画整体偏亮,在较亮的区域可以表现更多细节,而直方图均衡后的图整体会更暗一些。计划直接匹配一种较亮的灰度直方图,目前还没实现。

等待后续补充完善。

结合形状和灰度的一致性评价

经过一段时间的尝试后,我发现像素均方误差损失函数不能刻画灰度上的一致性

因为字符图片的像素大多为纯黑或纯白,各像素到某一中间灰度的距离始终是很远的。区域灰度存在不同像素的互补平衡,而像素级的绝对值距离叠加无法反映像素间互补关系。

所以我加入了第二个评价指标:灰度距离损失函数,它的形式为$J=(\Sigma_i\Sigma_j{I_{ij}-\hat{I}_{ij}})^2$

然后总损失函数由像素均方误差损失函数、灰度距离损失函数带权叠加。

改进效果如下,原图:

图7

图8

改进后:

图8

图8

可以看到图中既有较多的形状也有一些灰度色块,两者取得了初步的平衡。可以简单地设置两损失的权重来调节字符画的形状倾向和灰度倾向。

需要注意的是,两个损失函数需要统一量级。如果将平方$square$改为绝对值$abs$的话,两损失函数对不同像素的作用很难对齐,并且权重调整的效果也不那么好。初始权重的效果如下:

图8

图8

看到生成的字符画比较偏向灰度;实际上这种方案很难通过调整权重找到两者的平衡。

Nonebot2 源码

我在QQ机器人Nonebot2框架中完成了上述思路的Python实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import numpy as np
from PIL import Image, ImageFilter, ImageDraw
from PIL.Image import Image as IMG
from PIL.ImageOps import equalize
from typing import List, Dict, Optional

from nonebot_plugin_imageutils.fonts import Font
from nonebot_plugin_imageutils import BuildImage, Text2Image

from .download import load_image
from .utils import UserInfo, save_gif, make_jpg_or_gif, translate
from .depends import *

charpic_char_map = r' `1234567890-=qwertyuiop[]\\asdfghjkl;\'zxcvbnm,./!@#$%^&\*\(\)_\+QWERTYUIOP{}\|ASDFGHJKL:"ZXCVBNM<>\?'
charpic_char_num = len(charpic_char_map)
charpic_char_font = Font.find("Consolas").load_font(15)
charpic_char_img = None  # (char_num, h, w)
def _init_charpic():
    global charpic_char_img
    def make(char) -> BuildImage:
        text = "\n".join([char])
        w, h = charpic_char_font.getsize_multiline(text)
        text_img = Image.new("RGB", (w, h), "white")
        draw = ImageDraw.Draw(text_img)
        draw.multiline_text((0, 0), text, font=charpic_char_font, fill="black")
        return BuildImage(text_img)
    charpic_char_img = list()
    for char in charpic_char_map:
        img = np.asarray(make(char).convert("L").image)
        charpic_char_img.append(img)
    char_h, char_w = charpic_char_img[0].shape
    for i in range(charpic_char_num):
        charpic_char_img[i] = charpic_char_img[i][:char_h, :char_w]
    charpic_char_img = np.stack(charpic_char_img, axis=0)  # (char_num, h, w)

def charpic(img: BuildImage = UserImg(), arg: str = Arg()):
    if charpic_char_img is None:
        _init_charpic()
    _, char_h, char_w = charpic_char_img.shape

    def make(img: BuildImage) -> BuildImage:
        img = img.convert("L").image
        if '平衡' in arg:
            img = equalize(img)
        img = np.asarray(img)
        img_h, img_w = img.shape
        img_h_ = img_h if img_h % char_h == 0 else ((img_h // char_h) + 1) * char_h
        img_w_ = img_w if img_w % char_w == 0 else ((img_w // char_w) + 1) * char_w
        img_ = np.ones((img_h_, img_w_), dtype=np.int32) * 255
        img_[:img_h, :img_w] = img

        p_h = 0
        while p_h < img_h_:
            img_h = np.repeat(np.expand_dims(img_[p_h:p_h + char_h], axis=0), charpic_char_num, axis=0)
            p_w = 0
            while p_w < img_w_:
                bias = img_h[:, :, p_w:p_w + char_w] - charpic_char_img
                grayscaleloss = np.square(bias.mean(2).mean(1))
                l2loss = np.square(bias).mean(2).mean(1)
                loss = grayscaleloss + l2loss
                img_[p_h:p_h + char_h, p_w:p_w + char_w] = charpic_char_img[loss.argmin()]
                
                p_w = p_w + char_w
            p_h = p_h + char_h
        img_ = Image.fromarray(img_)
        return BuildImage(img_)

    return make_jpg_or_gif(img, make)