筛选和管理字体的一些经验

做 OCR 数据合成需要三个条件,字体,语料,背景图。之前闹过一段字体荒,只有几百个字体,生成出来的效果比较单一,然后一口气收集了 2W 个字体,可把自己坑了。

字体文件中常见的问题

为啥说把自己坑了呢?因为字体真的不像是大家想象的那么简单,里面其实会遇到各种情况,包括但不限于:

  1. 字体文件里面没这个字
    • 这是最最最常见的情况了,99.9% 的字体文件都会出现这种情况。如果一个字体能兼容各种字符,那简直是宝藏字体,赶快珍藏
    • 这个也是最最简单的情况,只需要读取一下「这个字体文件支持啥字符」就行了
  2. 字体文件里这个字是错的
    • 这个就比较烦了,而且大多数情况下用代码是解决不了的
    • 比如,有些字体在遇到「没这个字」的时候,会变成空白、方框、方框叉叉、两道杠
    • 比如,将藏语塞进了原本属于拉丁字母的区域
    • 比如,中文里面为了可爱,将「信」替换成了「✉️」的表情(此处忍住不说脏话……
  3. 需要将符号字体干掉
    1. 符号字体还是很多的,比如奥林匹克运动项目字体,复旦 Logo 字体,笑脸字体,海底生物字体,军舰字体,惊雷体(这个字体就叫做 Thunder,就给翻译成 惊雷 了),「历史上的名人」字体(里面有美国历届总统、名人的头像)等,都应该被干掉
    2. 还有一些是 Demo 字体,不一定哪个字符被映射到了「这是个 Demo,请购买完整字体」的 Logo 上,也需要被干掉
  4. 有些字体很花哨,不适合用来做数据生成
    • 比如英文中的一些手写体,很飘逸,人眼辨识起来就像在看医生的处方笺
    • 有一些字体形式远远大于内容,例如小人举牌体,就应该被干掉
    • 有一些字体十分抽象,例如莫尔斯码体,例如数字用的是二进制的,也应该被干掉

2、3、4 的一些例子可以见我筛字体的时候发的 Twitter:


我是怎么筛选字体的

2W 字体,挨个看,会出人命的(额,话虽这么说,我自己已经人工筛过不下 10 遍了,每次筛完都是眼冒金星)。所以我选择使用脚本+工具来进行筛选。

Python 筛选脚本

首先写一个 Python 函数,获取每个字体文件都支持啥字符

import pickle
import sys
from argparse import ArgumentParser
from concurrent.futures import ProcessPoolExecutor, as_completed
from functools import partial
from hashlib import md5
from pathlib import Path

from PIL import ImageFont
from fontTools.ttLib import TTFont
from tqdm import tqdm


def get_font_support(font: Path):
    supported_chars = set()
    with TTFont(str(font), 0, allowVID=0, ignoreDecompileErrors=True, fontNumber=-1) as ttf:
        for table in ttf["cmap"].tables:
            supported_chars |= table.cmap.keys()

    result = set()
    for c in supported_chars:
        bbox = ImageFont.truetype(str(font), 12).getmask(chr(c)).getbbox()
        if bbox is not None or chr(c) in " \t":
            result.add(c)

    return result


def doit(src: Path, dst: Path):
    tqdm.write(f"Processing {src}")
    md5sum = md5(src.open("rb").read()).hexdigest()
    pickle_file = dst / f"{md5sum}.pkl"
    if not pickle_file.exists():
        try:
            font_support = get_font_support(src)
            pickle.dump(font_support, pickle_file.open("wb"))
        except Exception as e:
            tqdm.write(f"Error while processing file {src}: {e}", file=sys.stderr)
    else:
        tqdm.write(f"File {src} is processed, pass", file=sys.stderr)


if __name__ == '__main__':
    parser = ArgumentParser()
    parser.add_argument("src", type=Path)
    parser.add_argument("dst", type=Path)
    parser.add_argument("-T", "--threads", type=int, default=8)
    args = parser.parse_args()

    if not args.dst.exists():
        args.dst.mkdir(exist_ok=True, parents=True)

    fn = partial(doit, dst=args.dst)

    with ProcessPoolExecutor(max_workers=args.threads) as executor:
        jobs = [executor.submit(fn, x) for x in args.src.rglob("*.*")]
        for job in tqdm(as_completed(jobs), total=len(jobs)):
            pass

这个脚本的功能是,获取到所有支持的字符,并把字符画出来,获取边界框。如果画出来的东西没有边界框,那么也认为这个字体不能画出这个字符。不过这里有几个小坑:

  1. 脚本运行会很慢,处理 2W 个字体需要大概 10 个小时。所以最好多线程来跑,并且做个缓存。
  2. 缓存的话,pickle 啥的都行,看自己喜好加。我是将字体取 md5,然后缓存了 result
  3. 多线程的话,如果遇到用 futures.ProcessPoolExecutor 跑报错的话,用 multiprocessing.set_start_method('fork') 救一下就好了。futures.ThreadPoolExecutor 我没跑起来,不知道是什么错
  4. 脚本有时候会出错,记得做好 Try Catch

然后确定你需要的字符范围,比如西里尔文字母,或者某个国家的全部字母。如果不幸,你的任务是中文和日文,那么只能随机取一些字了:

  • 中文的话,我喜欢用「永和九年,岁在癸丑,暮春之初,会于会稽山阴之兰亭,修禊事也。群贤毕至,少长咸集。此地有崇山峻岭,茂林修竹,又有清流激湍,映带左右」和「归去来兮,田园将芜胡不归,既自以心为形役,奚惆怅而独悲,悟以往之不谏,知来者之可追」
  • 日语的话,随便找了一句「めぐり逢ひて 見しやそれとも わかぬ間に 雲隠れにし 夜半の月かな」,最好一句话里面同时有平假名和片假名

最后设定规则,如果你选定的字符范围中有超过 80% 的字符都被某个字体所支持,那么恭喜,大胆地将这个字体收入囊中吧~

人工筛选

最上面说了,脚本不能搞定所有 Case,所以还需要人工来筛一下。这里推荐 《RightFon 5》 这个 app,收费的,不算太贵(哦反正让公司财务采购了十几个,我们组每人一个,不是我出钱),算是用过的字体管理类 App 里面最好用的了。

创建一个新的字体列表,将前面自动筛选过的字体全部导入这个列表,然后右上角输入你希望检视的文字,并开始快速预览,将有问题的字体(一般是灰色显示)从列表中删掉(或者将没问题的字体添加到另外一个列表中,因为删除目前有 Bug)。

最后是将列表导出。导出之后的东西就能用啦~不过文件名可谓一头雾水,我目前不太能摸得准文件名的规律——完全不影响使用。

我是怎么管理字体的

依然是用 RightFont 5。在里面建立了一个字体库,将左右字体一股脑倒腾进这个字体库里面。

然后,用上面的筛选方法筛选字体,依据语言支持情况分门别类建立字体列表:中文、日语、韩语、拉丁字母、西里尔文、阿拉伯语字母、泰语、藏语、符号字体……

然后里面再手动细分:常规体,手写体,全大写、全小写、简体/繁体、点阵……

嗯这样就差不多了。

吐槽

即使用上了 RightFont,人工筛选也是蛮耗费时间的……比如筛基本拉丁字母的字体,花了我整整三天,眼都快看瞎了。

所以,还是需要写一个更强大的脚本,替我判断一些东西,比如大写和小写是否一致,等等。——难度有点大,再说吧

发表评论