Python+Pillow批量生成汉字字体图:从单字渲染到OCR数据集全流程
前阵子接了个小需求把3500个常用汉字批量渲染成固定尺寸的透明背景PNG给单位的字库做标注样本。第一次用PS的“导出图层”功能折腾到凌晨两点后来发现这种事根本不该手工干。只要思路对了Python脚本几分钟就能出一套还能顺便控制字体、字号、描边、阴影甚至生成带角标的训练集图片。这篇就聊聊我批量生成字体图的完整思路、代码和踩过的坑适合想自己搞定字形渲染、OCR样本准备或字体效果预览的人参考。1. 内容整体设计与思路拆解1.1 先搞清楚你要生成哪种“字体图”批量生成字体图听起来简单但“字体图”在不同场景里根本不是一回事。我通常先按用途分成三类因为这三类的输出格式、尺寸策略和特效取舍差别很大。第一类是纯字形图最常见的是单个字符透明背景PNG或者黑底白字。这种图的核心要求是字形完整、边缘干净、尺寸统一常用于字库测试、字形比对、字体识别模型训练。重点看字体渲染器是否保留原始笔画细节以及导出后有没有裁切、缩水。第二类是带特效的风格图比如阴影字、渐变字、背景纹理加文字。这种图会出现在海报素材批量产出、短视频标题字、PPT配图等场景。核心要求是效果可控、风格统一比如同一字体同一阴影角度28个字都保持一致这是手工PS很难保证的。第三类是带信息标注的数据集图在生成字形图的同时把字符Unicode码点、字体名称、是否加粗等信息写入文件名或图片角落。这类图主要给机器学习标注、样本归档、自动化测试用命名规范和批量一致性比肉眼好看更重要。我这篇文章主要围绕第一类和第三类展开第二类会顺带提特效参数怎么加但不展开讲复杂合成。1.2 技术选型为什么我用Python加Pillow而不是其他方案批量生成字体图可以走好几条路先说结论日常90%的批量字形生成需求我用Python加Pillow就够了只有当需要复杂排版时才切HTML渲染方案。拿Pillow和ImageMagick对比ImageMagick的convert命令也能做文字渲染一条指令处理单张图很方便但它的问题是中文字体加载偶尔玄学复杂绘图逻辑要拼命令字符串一旦需求从“批量做图”变成“批量生成后还要按字体分组归档、生成统计JSON”命令行方案就力不从心。Pillow的编程模型更直接字体加载就是ImageFont.truetype()画字就是draw.text()整个流程都在代码里可读性和扩展性高。还有一种方案是写HTML加CSS用无头浏览器截图。这种方案的排版能力极强但两个缺点很致命一是每张图都要启动一次渲染进程批量几百张时速度慢二是字体加载和坐标系不可控做数据集图时很难精确到像素级。我只有在生成多行文字的“卡片图”时才考虑这个方案。至于“用AI绘图工具生成字体图”我完全不推荐批量场景用。AI生成字形不可控笔画会错无法保证每个字都正确它适合做艺术字创意但不适合做批量生产。哈希确定性也很重要用编程方案渲染文字同样的输入可以得到像素级一致的输出这在做测试和数据集时是硬性要求。这也是我选Pillow的核心理由之一。1.3 批量流程的整体闭环我用一句话概括批量生成字体图的流程读取字符列表循环渲染每个字符按规则命名分目录输出。听起来简单但实际写代码前要先定四件事字符列表从哪来是写死的字符串还是从文本文件、数据库读取字体文件在哪Windows下C:\Windows\FontsmacOS下/System/Library/FontsLinux下/usr/share/fonts但更稳妥的做法是项目目录内放一份授权允许的字体文件。输出图片格式与大小PNG还是JPEG单字画布是256×256还是512×512边距留多少。命名规则避免重名、避免特殊字符导致的文件系统问题。这些在动手前想清楚后面写代码就是填内容。我不想上来就贴全量代码先把关键细节说透这样你拿到代码后改起来才不懵。2. 核心细节解析与实操要点2.1 字体文件加载与字符编码字体文件格式这块有个常见误区很多人以为.ttf、.otf、.ttc都一样直接改后缀名就行。其实不是。Pillow的ImageFont.truetype()支持.ttf和.otf支持.ttc但.ttcTrueType Collection是多个字体打包在一个文件里的加载时需要用index参数指定取第几个字体。比如msyh.ttc包含微软雅黑和微软雅黑粗体等多个字形不指定索引默认加载第一个。实际使用中只要不是必须用某个ttc里的特殊字重我建议优先转成单字体的ttf或otf避免索引错位。字符编码上的坑更大。字体文件里不是每个字符都有对应字形一个常见现象是用Source Han Sans CN渲染一个生僻字画布上出现方框或空白。这不是Pillow的错是字体本身缺字形。批量生成前要检查字符覆盖率靠谱做法是用font.getmask()或者直接渲染后用像素统计判断是否真的画出来了见第4章的排查表。另外字体文件名在不同平台差异极大。比如思源黑体Windows下可能是SourceHanSansCN-Regular.otfmacOS下可能是SourceHanSansSC-Regular.otf大小写还不一样。代码里写死路径后换台机器就崩。我的习惯是字体文件都复制到项目目录下的fonts/文件夹里代码用相对路径引用跟系统环境解耦。2.2 画布尺寸与锚点为什么你的字总被裁掉这是我被问得最多的问题。很多新手用draw.text((0, 0), char)直接在左上角画结果大字号时字形下半部分直接出画布。原因很简单draw.text()的坐标是文本包围盒左上角不是字形中心而且这个包围盒会含内边距字越大越明显。可靠做法分两步第一步画布尺寸按“字号两倍边距”计算我常用公式是canvas_size font_size 2 * margin 2 * stroke_width。第二步用anchormm让坐标指向文本中心把坐标设为(canvas_size / 2, canvas_size / 2)。anchormm是Pillow从8.0开始支持比较完善的锚点参数mm表示水平居中、垂直居中。实测下来用这个参数后单字图基本不会出现方向性裁切。唯一要注意的是不同字体的实际字形高度不完全等于font_size比如同为256号字思源宋体比思源黑体视觉上可能矮一点。如果你要求多字体的字形视觉高度一致需要先渲染一遍测出实际像素高度再缩放到统一画布这一步不做的话不同字体混在一起就很突兀。2.3 描边、阴影和底色特效参数的几处关键点批量生成带特效的字体图三个最常见参数是stroke_width描边宽度、stroke_fill描边颜色和绘制阴影的偏移量。描边的实现原理是Pillow在字形轮廓内外同时扩展绘制所以描边会向字形内部和外部两个方向扩展。这意味着如果你不调整画布尺寸描边越宽字形边缘越容易被裁切。我见过有人描边设成30像素字直接糊成一团因为内外扩展叠加后把笔画堵死了。经验值是描边宽度不要超过字号的10%比如256号字最多用25左右再大就要同时放大画布并减小字号。阴影的正确做法不是直接画一层灰色文字而是先画阴影再画主体。Pillow的绘制顺序就是覆盖顺序阴影先画、主体后画主体才能遮住阴影的中心区域形成错落效果。偏移量我习惯取字号的3%到5%太大像贴纸太小没层次。底色处理要注意如果输出PNG且要保留透明背景画布模式必须用RGBA而不是RGB。RGB模式下透明区域会被填充成黑色导出后看着是黑底这个坑我在第4章详细说。2.4 批量集中的“一致性”保障批量生成最怕的就是前100张和后100张效果不一致。常见原因有两个一是字体对象反复加载导致状态不一致二是某些绘制参数被循环内意外修改。我习惯把不变量和变量分开。字体路径、字号、边距、描边、颜色这些都是不变量在循环外定义字符本身是变量。每次渲染时不要重新计算这些值直接引用预定义的常量。这样即使中途改动需求也只需要改顶部配置。另外一个保障是命名规则里带上参数哈希或者完整参数描述。比如楷体_256px_stroke10_白底.png哪怕后面改了参数也能从文件名直接看出旧图是哪套配置生成的避免把旧图混进新数据集。3. 实操过程与核心环节实现3.1 项目结构与初始配置我的批量生成项目一般长这样font_batch/ ├── fonts/ │ ├── SourceHanSansCN-Regular.otf │ └── SourceHanSerifCN-Regular.otf ├── chars.txt ├── config.py └── batch_generate.pychars.txt里每行一个字符或者直接一行字符串读取时按字符拆分。这样要改生成范围直接改文本文件就行不用动代码。config.py放所有不变量方便集中调整# config.py from pathlib import Path BASE_DIR Path(__file__).parent FONT_DIR BASE_DIR / fonts OUTPUT_DIR BASE_DIR / output FONT_PATHS { sans: str(FONT_DIR / SourceHanSansCN-Regular.otf), serif: str(FONT_DIR / SourceHanSerifCN-Regular.otf), } FONT_SIZE 192 # 字号单位像素 MARGIN 48 # 四周留白 STROKE_WIDTH 4 # 描边宽度不需要就设0 OUTPUT_SIZE None # None表示按字号边距自动计算也可以固定成256 BG_COLOR (255, 255, 255, 0) # 透明背景 TEXT_COLOR (30, 30, 30, 255) # 近黑色文字 STROKE_COLOR (200, 30, 30, 255) # 红色描边为什么用RGBA元组给颜色因为透明背景的PNG必须显式指定alpha通道(255,255,255,0)表示完全透明(30,30,30,255)表示完全不透明。后面代码里所有Image.new()都要用RGBA模式。3.2 核心渲染代码从单字到批量第一步写单字渲染函数。这一步先保证“渲染一张图的质量”再谈批量。核心就是加载字体、建画布、画字、保存。# generate_one.py from PIL import Image, ImageDraw, ImageFont import config def render_char(char, font_path, font_sizeconfig.FONT_SIZE, marginconfig.MARGIN, stroke_widthconfig.STROKE_WIDTH): # 根据字号、边距、描边计算画布尺寸 canvas_size font_size 2 * margin 2 * stroke_width # RGBA模式背景全透明 img Image.new(RGBA, (canvas_size, canvas_size), config.BG_COLOR) draw ImageDraw.Draw(img) # 加载字体注意复用字体对象而不是每张图都重新加载 font ImageFont.truetype(font_path, font_size) # 使用anchormm让文字中心对准画布中心 center canvas_size / 2 draw.text((center, center), char, fontfont, fillconfig.TEXT_COLOR, anchormm, stroke_widthstroke_width, stroke_fillconfig.STROKE_COLOR) return img # 单字测试 if __name__ __main__: img render_char(永, config.FONT_PATHS[sans]) img.save(test_yong.png)这段代码看起来简单但每个参数都有讲究。canvas_size font_size 2 * margin 2 * stroke_width如果描边宽度是4那么笔画最外侧可能比字形原始边界宽出4像素上下左右总共就是8像素所以加2 * stroke_width。如果不加描边后边缘会被画布边界切掉。保存前你还可以做一个裁剪优化字形实际渲染出来四周可能有大量空白。如果嫌文件大可以用img.getbbox()拿到非透明区域边界再crop()掉多余空白但注意裁剪后图片尺寸会不一致。对于数据集图我建议保留统一画布不裁剪省时省心。第二步写批量入口。从文本文件读字符逐个渲染按规则命名保存# batch_generate.py from pathlib import Path from concurrent.futures import ThreadPoolExecutor from PIL import ImageFont import config from generate_one import render_char CHARS_FILE config.BASE_DIR / chars.txt OUTPUT_DIR config.OUTPUT_DIR / sans_192px def batch_render(): OUTPUT_DIR.mkdir(parentsTrue, exist_okTrue) # 读取字符去掉换行和空白 chars [] for line in CHARS_FILE.read_text(encodingutf-8).splitlines(): for ch in line.strip(): if ch ! : chars.append(ch) # 单线程直接循环字符量大再上线程池 for ch in chars: img render_char(ch, config.FONT_PATHS[sans]) # 用Unicode码点字符字体名命名避免奇怪字符影响文件系统 filename f{ord(ch):05d}_{ch}_sans.png img.save(OUTPUT_DIR / filename) img.close() if __name__ __main__: batch_render()这里把字符读取拆成两层外层按行读内层把每行拆成单字。这样chars.txt里可以写中文逗号、英文逗号或者直接连在一起都不影响因为我们是按字符拆不是按分隔符拆。命名里加上ord(ch)有讲究。汉字“一”和数字“一”虽然可能互相转换但Unicode码点是唯一标识。文件名里带上5位码点一方面避免不同字符文件重名另一方面按文件名排序时能自然按码点顺序排方便审查有没有漏字。我用05d格式化因为Unicode最大到六位但常用字基本都在0x4E00-0x9FA5区间五位够用。3.3 性能优化几百字无感几万字怎么办上面这段代码跑3500个常用字单线程大约需要几十秒到一两分钟取决于字号和电脑CPU。如果你要生成几万个字符或者同一个字符跑几十种字体就要考虑优化。先做最基础的优化字体对象只加载一次而不是每张图都重新加载。ImageFont.truetype()内部要解析字体文件、建立字形索引这个开销不小。实际使用中可以把字体对象作为参数传进render_char或者用functools.lru_cache缓存。我的经验是当字符量超过一万时用ThreadPoolExecutor提升明显因为Pillow的光栅化在单个字符上会短暂释放GIL。但要注意字体对象不要在线程之间共享。最稳妥的做法是每个线程在初始化时自己加载一份字体对象。ThreadPoolExecutor的initializer参数正好干这个# batch_generate_threaded.py 片段 from concurrent.futures import ThreadPoolExecutor def render_batch_concurrent(char_list, font_path, output_dir): output_dir.mkdir(parentsTrue, exist_okTrue) def _worker(ch): # 每个线程独立加载字体避免共享对象的不确定行为 from PIL import ImageFont font ImageFont.truetype(font_path, config.FONT_SIZE) img render_char_with_font(ch, font) filename f{ord(ch):05d}_{ch}.png img.save(output_dir / filename) img.close() with ThreadPoolExecutor(max_workers8) as pool: pool.map(_worker, char_list)render_char_with_font就是把render_char稍作改造接受外部传入的font对象。注意worker函数里每处理一个字符就重新加载字体是不划算的正确写法是用线程局部存储但为了代码简洁上面只是示意。真正跑大批量时我会把字体加载放到一个线程local变量里或者使用initializer提前加载。性能对比供参考单线程处理3500个256×256透明PNG大概60到90秒。8线程处理后能压到20到30秒。如果你用的是固态硬盘瓶颈通常反而不是CPU而是大量小文件写入。所以大批量生成时输出目录尽量放在本地SSD不要放机械硬盘或网络盘。3.4 输出格式与质量细节PNG和JPEG的选择字形图、数据集图用PNG因为需要透明背景和边缘无损。JPEG不支持透明通道纯白背景可以考虑但有压痕风险字形边缘会模糊。另有一个很多人忽视的点PNG保存时参数不是越多越好。img.save(path, optimizeTrue)在批量生成时反而慢且文件不一定更小。我的做法是普通PNG直接保存如果项目对文件大小敏感再用pngquant之类的工具做二次压缩不要在脚本里慢慢优化。对于“带角标”的数据集图我会在生成完字形图后在左上角用很小的字号画一行类似码点_字体名的标记。注意角标文字本身会被记录进RGB通道如果之后要做OCR测试数据这张图就不能再当“纯字形”用。所以数据集图片我会分成“纯净版”和“标注版”两个目录互不污染。4. 常见问题与排查技巧实录4.1 常见问题速查表现象根本原因解决办法输出图片全是黑色方块字体不支持该字符没有对应字形换字体或用font.getlength()预检覆盖率透明背景导出成黑底Image.new()用了RGB而不是RGBA改成Image.new(RGBA, ...)字形被裁切、上下不齐坐标没用anchormm且画布边距不足用中心坐标anchormm增大margin描边后字变糊、笔画粘连描边宽度超过字号10%调低stroke_width同一个脚本换电脑跑字体文件找不到路径写死系统字体目录字体复制进项目fonts/下相对引用批量中途崩溃“memory error”每张图没img.close()句柄泄漏循环末尾img.close()同一字符在两种字体里视觉大小差很多字体实际字面高度不同加一步渲染后按getbbox()归一化尺寸4.2 我实际踩过的三个坑第一个坑是透明背景黑边。我第一次写批量生成字图时用了Image.new(RGB, ...)然后保存为PNG。结果所有白色字形周围都有黑边因为RGB模式下画布默认黑色透明区域变成了黑色像素。后来把模式改成RGBA并设置背景为(255,255,255,0)问题才解决。这个坑特别隐蔽因为屏幕上看不太明显一旦用于白底页面或机器学习训练黑边会严重影响效果。第二个坑是字体对象跨线程复用导致偶发乱码。有一版优化我图省事在全局加载一次字体对象然后丢给8个线程共用。结果跑几千个字符时偶尔有个别字符渲染成方块或缺笔画。排查了很久发现是Pillow的字形缓存不是完全线程安全不同线程同时查询同一字形索引时会冲突。改成每个线程独立加载字体后问题消失。生产脚本里这种不确定性很致命宁可多耗点内存也要保证输出稳定。第三个坑是Windows路径与Linux路径不一致。我在Windows上开发时代码里写了C:/Windows/Fonts/msyh.ttc发给同事在macOS上跑直接报错。后来统一把所有字体文件复制到项目目录用Path(__file__).parent / fonts拼接路径才彻底摆脱系统字体环境差异。现在哪怕把整个项目打包扔到服务器上也能直接跑。4.3 一个容易被忽略的覆盖检查技巧批量生成后怎么快速确认没有漏字、有没有哪个字体缺字形我习惯生成后写一个check_coverage.py用Pillow打开生成的图片统计非透明像素比例。像素比例低于某个阈值比如1%就标记为可疑输出文件名到日志。这比肉眼一张张看高效得多尤其在跑几万张图的时候# check_coverage.py 片段 from PIL import Image from pathlib import Path def check_output_dir(img_dir, min_ratio0.01): suspicious [] for img_path in Path(img_dir).glob(*.png): img Image.open(img_path).convert(RGBA) alpha img.getchannel(A) # 统计非透明像素占比 bbox alpha.getbbox() if bbox is None: suspicious.append(img_path.name) continue area alpha.width * alpha.height non_transparent sum(1 for x in alpha.getdata() if x 0) if non_transparent / area min_ratio: suspicious.append(img_path.name) img.close() return suspicious if __name__ __main__: result check_output_dir(output/sans_192px) print(f可疑文件数: {len(result)}) for name in result: print(name)注意alpha.getbbox()会返回最外层非透明像素的包围盒如果返回None说明整张图完全透明那基本是字体缺字形导致什么都没画出来。这个脚本跑完基本上能在一分钟内圈定所有问题字符。5. 扩展场景与进阶玩法5.1 给字体图叠加网格、背景纹理和自定义角标基础版本跑通后下一步就是按场景叠加额外元素。比如给字形图加九宫格参考线用来检查不同字体的对齐关系这对字体设计师特别有用。网格线只需要在绘制字形前先画线def draw_grid(draw, canvas_size, grid_size32): for x in range(0, canvas_size, grid_size): draw.line([(x, 0), (x, canvas_size)], fill(200, 200, 200, 255), width1) for y in range(0, canvas_size, grid_size): draw.line([(0, y), (canvas_size, y)], fill(200, 200, 200, 255), width1)网格线用半透明灰既能看到又不干扰字形观察。如果输出PNG带透明背景网格线会直接浮在透明层上放到任何背景都能看到参考线比白底图实用得多。背景纹理这块如果用纯色背景直接Image.new(RGBA, size, bg_color)就行。如果要渐变背景可以逐行填充颜色但这会让图片体积变大批量生成时也需要重新考虑压缩策略。我的建议是渐变背景只在“少量精美字体展示图”场景用批量数据集图不要加。角标的设计原则是小而明确。我常用的是左上角画“码点_字符号_字体名”用10到14像素的字颜色跟背景区分开。这个角标有校验价值即使文件名被修改图片本身还保留了来源信息。5.2 同一套代码输出多字体对比图之前有个场景要对比三款中文字体在相同字号下的字形差异。我的做法是先定义字体列表然后每个字符生成一张三格横排图三格里分别渲染同一个字符的三款字体再用垂直线分隔。这种图用来给非技术同事做字体评审特别直观不用安装字体直接看图就能投票。实现上就是循环嵌套外层遍历字符内层遍历字体每个字体渲染到固定区域最后拼到一张宽图里。这里有个细节不同字体的基线高度不同拼图时如果不做垂直居中字体之间会上下错落。用anchormm配合每个字体的独立画布高度计算中心点基本能对齐。5.3 配合OCR、字体识别或模型训练做数据准备如果你是为了训练字体识别模型或OCR模型准备数据上面的代码已经覆盖了80%工作。剩下20%是数据增强比如随机缩放、旋转、加噪声、改变背景色等。Pillow做这类增强很方便缩放用img.resize()旋转用img.rotate()。但注意旋转会引入插值伪影字形边缘会模糊。做训练集时可以保留一部分旋转样本但一定要同时输出标注信息比如旋转角度否则模型训练时标签对不上。这里还有一个布局策略如果训练数据需要“整行文字识别”那单字图就不够了。需要把多个字符按顺序渲染到一行模拟真实句子。我的实现思路是先从语料里随机截取一段文本再逐字渲染到画布上字体和字号随机变化模拟真实排版。此时不能用anchormm逐字画因为需要精确控制字间距要改用anchorls按左基线对齐逐字推进坐标。5.4 封装成命令行工具或图形界面代码稳定后我把它封装成一个精简的命令行工具效果非常好python -m font_batch --chars 你好世界 --font sans --size 256 --output ./out命令行抽出来后有几个好处一是可以跟CI/CD流程整合比如每次提交字体文件后自动跑一遍批量渲染做回归测试二是团队其他成员不用读代码也能直接用。封装思路就是把config.py里的常量改成argparse参数然后默认值保持原样这样IDE里直接运行和命令行运行时行为一致。如果团队里非技术同事经常要用我还会加一个极简的桌面界面本质就是一个输入框、一个字体下拉框、一个按钮底层调用同一个批量函数。这不算复杂但真能提升工具的利用率。最后说点实际体会这段时间折腾下来最大的感受就是批量生成字体图的难点不在“画字”本身而在“如何让它稳定、可控、可排错”。只要把字体加载、坐标锚点、画布计算和命名规则这四个点想清楚脚本的基本盘就稳了。真正花时间的是后面的排查字体缺字形、线程复用、平台差异每一个都是现实的毒打换来的。如果你在做的也是类似需求建议先把单字渲染跑通、肉眼验证效果再上批量最后再加并发和增强。这几个顺序一旦颠倒排查问题时你会同时面对“效果问题”和“性能问题”很难定位。我自己最后把这套脚本固化成了一个小工具支持多字体对比、输出数据集格式、一键生成预览拼图。下次再接到“把这批字都渲染出来”的需求基本几分钟就能交付。这个方向还能继续扩展比如接入字体文件的自动下载校验、支持更多颜色配置、按标签自动分组都是顺手的事。愿你批量出图时少踩坑多出活。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →