中国人不说英语!
此文章讨论的内容即为汉字,基本只有认得汉字的人才有阅读的必要,所以没必要写成英文的。
This article is mainly about hanzi (Chinese Characters). Those who can not read Chinese probably don't need it, hence I write it in Chinese instead of English.
汉字本身是一种文字,但也可以作为图案去分析。计算机是否能学会汉字的偏旁部首这种概念,像武则天造“曌”字一样造出汉字来?这就是我最近在研究与实验的问题。
给世界各地的朋友们送去一点汉字震撼。
有什么用?
这时候肯定有人要问,“这种东西有什么用?”。这里我举出构建艺术世界和传统文化两方面的作用,也正是我目前正在致力于推进AI完成的方向。回顾一下它的预期用途,也正好可以提醒我们努力的方向。
异世界文字
作为游戏业从业者,我表示这简直太有用了。我们看看《原神》和《塞尔达》这两个不同意义上的大作。我在另一篇文章里也解释了。
原神里的璃月是中国文明主题的国度,里面出现了大量基于篆书做出修改的图案。图为若陀龙王副本上的一种文字图案,你一看第一个字就知道了是“雷”,代表本次的若陀龙王是雷元素;第二个字也多半能猜到是“水”或者“冰”。这里的雷是由篆书(以及汉字)的雷做出少许调整,在下面掺杂了原神里的雷元素符号形成的。
那么《塞尔达:王国之泪》里,四处都是像篆书又不是的文字。最典型的就是乾坤逆转技能图标里的这个符号,看着像时间的时的繁体,但又不很像。
那为什么不直接用古汉字之类的呢?这一点其实也有人探讨过了,为了营造异世界的氛围。原神里那是璃月,不是中国。塞尔达那是海拉鲁,不是中国。
一些游戏、动漫里不想造全新的语言,直接把字反过来或者小改一下,也是一样的道理。下图为大友克洋《大炮之街》里的截图,可以看到有类似“STOP THE POISON GAS”"WE WANT A PLLOTION FREE ENVIRONMENT NO MORE!!"的字,但又不完全是英文字母。
讲架空国度,就用架空文字。造字在当今的生产中是有实际需求的。如果AI能学出似是而非的文字,最好还能像这样嵌入,那是再好不过。
这是传统文化
另一方面,除了搞二次元构建架空世界,我国历来都有造字的文化传统,造字、生僻字、异体字数不胜数,在计算机汉字编码之前,字其实是非常灵活的。孔乙己那时候都知道回字有三四种写法。西夏文那种建立新王朝用的就不说了,现在基本不会有这种场景了。
比如,biangbiang面。这个造字背后有各种文化传统故事。背书,能作为地方特色。
传统图案“招财进宝”合体字。写四个字在一起,分开又如何?就是有人要写在一起。
“天书”中比较知名的一些,讲道教修行的。一旦具体内涵失传了,还能给后人当未解之谜去玩味。论中二,古人不输当代人。各位可以去AAAA级风景区河南五岩山里的老君古字碑上找到类似这样的造字,这里只列出其中14个常用作对联的。
这些都算是比较优秀的造字,我目前的AI还达不到这种程度,这算是后续的目标。
怎么造?
造biang、招财进宝、老君碑那样的拼接字现在已经有在线非AI工具了。
https://zi.tools/zi/%E9%9B%B7?secondary=ids
只不过,这个拼出来的缺少点书法感,也不能选字体,更像是ps或者ppt里直接硬凑的。它适合做学术研究,查找是否有这个字等,不适合做艺术处理。
训练LoRA的话,直接把字体每个汉字都导出黑底白字图片并不好。这样AI只能看到一大群乱七八糟的图案,不会建立偏旁部首的概念,出来的是完全不受prompt控制的汉字。
而且,汉字过多,穷举造成的数据量太大,目前的民用计算机硬件计算效率根本吃不消。
为了解决汉字过多的问题,我们要筛选出一部分汉字参与LoRA制作。
千字文
我的第一阶段都选用《千字文》的一千个字,“天地玄黄、宇宙洪荒”,多数字都算常用,古代也有,并且文字不重复,算是比较好的一个初学者数据集。首先我从百度找到了千字文的全文,去掉标点符号,就得到了含有空格的一千个汉字。过滤掉空格很简单,我一般保留输入文件中的空格、换行,方便之后阅读和查询。我把这个(简体)千字文存为input.txt。如果各位懒得自己找的话,可以用我附件里的qzw.txt
那么怎么把字符转化成图片呢?接下来我们只需要一个字体和一个脚本。字体各位大可随便找一个,或者选用台湾《小学堂》的篆书字体: https://xiaoxue.iis.sinica.edu.tw/chongxi/download.htm
脚本也好说。几次对话之后,我让GPT给我写出了这个python。它能把指定字体里,input.txt对应的每个字都给导出256x256的黑底白字png。各位可以随意拿去用。
import os
from PIL import Image, ImageFont, ImageDraw
# 字体文件路径
font_path = "zhuanshu.ttf" # 替换为你的字体文件路径
# 图像尺寸
image_size = (256, 256)
# 创建 output 目录(如果不存在)
if not os.path.exists("output"):
os.makedirs("output")
# 读取字符列表
with open("input.txt", "r", encoding="utf-8") as file:
characters = file.read().replace("\n", "").replace(" ", "")
# 加载字体
font = ImageFont.truetype(font_path, size=256) # 替换为你的字体大小
# 逐个生成图像和文本文件
for i, char in enumerate(characters):
# 创建一个新的图像对象
image = Image.new("1", image_size, color=1) # "1" 表示黑白图像,color=1表示初始背景为白色
# 创建绘图对象
draw = ImageDraw.Draw(image)
# 在图像上绘制文本
draw.text((0, 0), char, font=font, fill=0) # fill=0表示文本颜色为黑色
# 生成文件路径
image_filename = f"output/ch_{str(i).zfill(4)}.png"
text_filename = f"output/ch_{str(i).zfill(4)}.txt"
# 保存图像为PNG格式
image.save(image_filename)
# 保存文本文件
with open(text_filename, "w", encoding="utf-8") as text_file:
text_file.write(char)
print("图像和文本生成完成!")
导出成功之后像上图。每一个字都附带一个tag的文件,这个文件的内容就是这个字本身(比如第一个txt只有一个“天”字)。(你也可以继续批量编辑tag文件,给它加上monochrome, black and white 之类的标签,不过我目前的初步测试结果是这样做没什么效果。)
说文解字
接下来问题就来了,怎么让计算机学会偏旁部首呢?我暂时不知道有什么适合批处理的数据库,最后我想到了古书《说文解字》,电子版也很好搜到,我放到附件的shuowenjiezi.txt了。
说文解字有一个问题,那就是它是汉朝的许慎根据当时的文字做的,比较符合篆书和隶书的状态,与现在的繁体字楷书有一定出入,与简体字楷书的区别就更大了。因此我在这里直接使用了篆书字体,这样最为稳妥。不过,反正篆书做二次元是最适合的。原神和塞尔达都选了篆书,而且篆书现代人基本看不懂,不容易出现文字恐怖谷的感觉。如果你看下图感到不适,那么你就充分地理解了什么是文字恐怖谷。
话说回来,但是这古书的内容和格式有点散漫,怎么让他转化为精确地结构化描述呢?有请GPT给我们写出第二个脚本,专门根据这txt说文解字的格式生成对应的图片和描述。
import os
import re
from PIL import Image, ImageFont, ImageDraw
from fontTools.ttLib import TTFont
# 字体文件路径
font_path = "chongxi_seal.otf"
# 加载字体
font = ImageFont.truetype(font_path, size=256)
ttfont = TTFont(font_path)
# 图像尺寸
image_size = (256, 256)
# 创建 output 和 output2 目录(如果不存在)
for dir_name in ["output", "output2"]:
if not os.path.exists(dir_name):
os.makedirs(dir_name)
# 读取字符列表
with open("input.txt", "r", encoding="utf-8") as file:
characters = file.read().replace("\n", "").replace(" ", "")
# 读取输入文件
with open('input.txt', 'r', encoding='utf-8') as f:
lines = f.readlines()
# 字典映射“部”到其数字代号
bu_to_num = {}
count = 1
def char_in_font(Unicode_char, font):
for cmap in font['cmap'].tables:
if cmap.isUnicode():
if ord(Unicode_char) in cmap.cmap:
return True
return False
# 遍历每一行数据
for line in lines:
# 提取编号
id_ = re.search(r'编号:(\d+)', line)
if id_ is None:
continue
else:
id_ = id_.group(1)
# 提取部
bu_ = re.search(r'(\w+)部', line)
if bu_ is None:
continue
else:
bu_ = bu_.group(1)
if bu_ not in bu_to_num:
bu_to_num[bu_] = str(count).zfill(4)
count += 1
# 创建 output2 的子目录(如果不存在)
sub_dir = f'output2/6_bu_{bu_to_num[bu_]}'
if not os.path.exists(sub_dir):
os.makedirs(sub_dir)
# 提取部后的字符
char_after_bu = re.search(r'{}部\s+(\w)'.format(bu_), line)
if char_after_bu is not None:
char_after_bu = char_after_bu.group(1)
# 检查字符是否在字体中
if not char_in_font(char_after_bu, ttfont):
continue
# 提取从后的汉字
from_words = re.findall(r'从(\w)', line)
# 提取声前的汉字
sound_words = re.findall(r'(\w)聲', line)
# 提取切
qie_words = re.findall(r'(\w+切)', line)
# 将所有单词合并
words = [bu_, bu_ + '部', char_after_bu] + from_words + sound_words + qie_words
# 写入到 output 和 output2 的子目录的文件
for output_dir in ['output', sub_dir]:
with open(f'{output_dir}/ch_{str(id_).zfill(4)}.txt', 'w', encoding='utf-8') as f:
f.write(','.join(words))
# 创建图像并绘制字符
image = Image.new("1", image_size, color=1)
draw = ImageDraw.Draw(image)
# 检查字符是否在字体中
if not char_in_font(char_after_bu, TTFont(font_path)):
continue
draw.text((0, 0), char_after_bu, font=font, fill=0)
# 保存图像
image_filename = f'{output_dir}/ch_{str(id_).zfill(4)}.png'
image.save(image_filename)
“编号:26 示部 祇 qi2/chi2 地祇,提出萬物者也。从示氏聲。 巨支切 ”
导出的tag文件内容为:
示,示部,祇,示,氏,巨支切
说实话,导出“XX切”是否有必要我不是很确定,各位可以简单修改上述脚本删掉这一部分来做进一步的尝试,我这里时间有限,尚且没有尝试过。
我不需要把这一堆图片和文本打包上传,各位拿着那个字体和那个脚本,只需要三分钟左右就能在自己电脑上构造出来相同的这一万九千多个文件。
顺便一提,我让GPT写的这个脚本里加上了一个特殊处理,那就是遇到字体没有的字直接跳过,不然它会导出一个正方形出来,并且给予对应的tag,严重干扰我们的训练。
这个脚本是我第二轮训练使用的脚本,他把每个部首的内容都放到了不同的文件夹。《说文解字》总共有九千多个字,想扔到一起训练,6step x 20epoch,在RTX4090上ETA 40个小时以上,我花了五个小时才跑完了13%。
那么偷懒不练完直接用行不行呢?我这里尝试了epoch不足时生成“示部:1.4”的结果。
这个模型v0.3是艹、礻、王字旁和少数其他字一起练的,不出来其他两个偏旁基本就是胜利。可以看到,在第八个epoch时,结果甚至出现了严重的倒退,反而出现草字头和王字旁了。
根据测试,大约12个epoch才够用,因此我建议没有云服务器的炼丹师最初只尝试一小部分,以免在错误的参数上浪费过多时间。
2023-06-16更新:
随后我继续训练。每次使用100个部首,不论部首里的内容多还是少,一次100个。
于是我们得到:
https://civitai.com/models/87172/swjz-seal-character-vol0
https://civitai.com/models/91292/vol1-swjz-p15-seal-character
https://civitai.com/models/91299/vol2-swjz-p25-seal-character
https://civitai.com/models/91303/vol3-swjz-p35-seal-character
理论上还应该有一个vol4收纳最后的135个部首,但还没做。
结果的话,很难说是否理想。我注意到这次生成出来的图明显开始有毛刺了,没有最初千字文那版那么丝滑。我不确定是我4090要烧坏了,还是单纯的图太多了,或者是参数不合适。
比如,(頁部:1.2), <lora:shuowenP3V1.0_:1>,用DPM adaptive 采样器出来的结果是:
即使权重下调到1.0,还是有毛刺。
另外,在这浩如烟海的训练结果之中,模型还是只学会了部首。比如,XX部,出来的结果会很明显地带有部首;但“XX部,文”或者“XX部,水”并不能把对应的部首和另外的零件拼在一起。
再比如," (見部:1.2),(欠部:1.2),<lora:shuowenP3V1.0_:1>"也不能把两个部首拼在一起,出来的只会是这样的:
看不出欠部在哪里。如果你不知道欠部长什么样的话……
它长上面那样。如果生成了,一眼就能看出来。显然是没有出来。
还有一个尴尬的是,我为了减小性能压力,把lora拆成了好几个,每个都只能用于对应的部首,但用户不能无脑地把所有的lora都点进去,然后指望靠关键词自动启用所需的lora。试图偷懒,不查lora的部首对应关系,一股脑扔进去“ (欠部:1.2),<lora:shuowenP3V1.0_:1> <lora:shuowenP0V1.0_:1> <lora:shuowenP1V1.0_:1> <lora:shuowenP2V1.0_:1>”。结果只会是:
嗯,差不多这样。这就导致了其实用户在使用我的lora时,还得查询每个lora对应什么,不能无脑用,与我的初衷背离了,所以我暂停了训练,最后一部分就先没做了。
这就是当前的进度。