资讯动态

深度学习字体笔划分割:FCN、SegNet、UNet模型解析与实战

发布时间:2026/9/11 14:08:48 来源:尧图企业网站定制
简介这是一套基于Python与深度学习全卷积网络FCN实现的字体笔划分割解决方案源码面向对图像分割、OCR预处理或字形分析感兴趣的中高级开发者。项目覆盖数据加载、模型定义、训练、预测与评估的完整流程包含Unet、SegNet、FCN及自定义网络等多种分割模型并提供miou、fwiou、mpa等评价指标脚本便于横向对比不同网络在笔划分割任务上的效果。资源共25个文件以py源码为主辅以yaml环境配置、图片样例与说明文档整体约200KB结构紧凑适合直接阅读与二次开发。当前已有338人学习代码按功能模块组织可帮助理解全卷积网络在字体图像分割中的实际应用与调参思路。对希望将深度学习落地到文档图像分析的读者而言这是一份完整的端到端参考实现。1. 为什么字体笔划分割要单独走一套 FCN 流程一份字体笔划分割源码和常见分割项目最大的区别在于笔画宽度可能只有 2 到 3 像素交叉点、粘连处密集普通分类网络做滑窗不仅慢还容易把横折钩当成多个独立笔画。这个基于 Python 的深度学习方案把任务建模成全卷积网络FCN的像素分类问题并额外提供了 SegNet、UNet 和自定义 mynet 三个模型文件训练、预测、评估脚本齐全适合做字库重建、OCR 预处理或书法风格分析的工程师二次开发。整套工程里真正要花时间读的是 models 目录和四个训练脚本。fcn.py 解决如何把分类网络改成分割网络segnet.py 解决显存有限时怎么保留细节unet.py 解决用跨层拼接找回细笔画mynet.py 留给业务方做结构修改。我拿到包后会先看 readme.txt、env.yaml、train300.py 确认环境入口和数据格式因为后面调参时影响质量的主要是标签映射、上采样方式和指标选择而不是网络深度。下面按源码模块顺序拆开讲。2. 从 models 目录看 FCN、SegNet、UNet 的选型差异2.1 源码文件先清点训练入口和模型一一对应先不说网络结构先把压缩包里的文件按职责分一下组。models 目录下的四个 py 文件是模型定义train*.py 是训练入口dataset.py 负责把图片和标签组织成 batchevaluate/miou.py/fwiou.py/mpa.py 是评估组件。目录结构大致如下# 与模型训练直接相关的文件 model_train/ ├── models/ │ ├── fcn.py # FCN 实现 │ ├── segnet.py # SegNet 实现 │ ├── unet.py # UNet 实现 │ └── mynet.py # 自定义网络 ├── train.py # 默认训练入口 ├── train-fcn.py # 单独训练 FCN ├── train-segnet.py # 单独训练 SegNet ├── train-unet.py # 单独训练 UNet └── train300.py # 固定 300 轮次的训练脚本train-unet.py 和 models/unet.py 对应train-fcn.py 和 models/fcn.py 对应train-segnet.py 和 models/segnet.py 对应。train300.py 从命名看是一个固定 300 轮次的训练脚本通常用于先跑通数据链路和确认 loss 能降下去train.py 则可能是面向多模型或参数化调优的通用入口。我一般会在第一次接触这个包时先用 train300.py 做冒烟测试而不是直接完整训练。2.2 FCN卷积化加跳跃连接基础分割器FCN 的核心改动是去掉全连接层把最后的分类层变成 1x1 卷积再用转置卷积把特征图恢复到输入分辨率。对字体笔划分割来说只恢复比例不够笔画边缘非常依赖浅层特征所以 FCN-8s 会融合 pool3、pool4 和 pool5 三路输出。这一类实现里的跳跃融合思路可以简化成下面的片段import torch import torch.nn as nn import torch.nn.functional as F class FCN8s(nn.Module): FCN-8s 的 decode 部分演示三路跳跃融合 def __init__(self, num_classes2): super().__init__() self.score5 nn.Conv2d(512, num_classes, 1) self.score4 nn.Conv2d(256, num_classes, 1) self.score3 nn.Conv2d(128, num_classes, 1) def forward(self, pool3, pool4, pool5): # pool5 先上采样 2 倍与 pool4 对齐 score5_2x F.interpolate(self.score5(pool5), scale_factor2, modebilinear, align_cornersTrue) score4 self.score4(pool4) score5_2x # 再上采样 2 倍与 pool3 对齐 score4_2x F.interpolate(score4, scale_factor2, modebilinear, align_cornersTrue) score3 self.score3(pool3) score4_2x # 最终上采样 8 倍回到原尺寸 out F.interpolate(score3, scale_factor8, modebilinear, align_cornersTrue) return out这段代码的输入是三个不同层级的特征张量pool3 分辨率最高但语义弱pool5 语义强但分辨率低。通过逐级上采样相加FCN 把高层语义和低层边缘细节拼在一起。参数里num_classes在笔划任务中通常设置成“背景 笔划类别数”比如只分横、竖、撇、捺时就传 5align_cornersTrue是为了让上采样后的像素对齐在角点上避免细笔画出现半个像素的错位。如果只想快速验证可以去掉 score3 支路退化成一个 FCN-16s但横折钩这类长笔画会明显更糊。实际调参时我不建议一上来就加深网络而是先看 pool5 的输出在细笔画上是否还有响应。2.3 SegNet池化索引上采样省内存SegNet 同样基于编码器但上采样不做可学习的转置卷积而是记录编码器池化时每个窗口最大值的位置解码时把值放回对应位置。这样省掉的参数比 FCN 多训练显存更低。代价是最大池化索引只保留了空间位置丢失了幅度信息对书法字这种边缘渐进变化的区域恢复的笔锋不如 FCN 平滑。在笔划粘连的样本上SegNet 的输出容易出现块状伪影但它胜在稳定、易复现。实际使用中如果机器在 4GB 显存以下我一般会优先用 SegNet 跑出 baseline再换 UNet。2.4 UNet对称编解码加拼接捕捉连续笔锋UNet 在编码器每个层级后面都有一条跨层连接把同尺寸的解码特征和编码特征在通道维拼接。这个设计对细线结构非常友好等于每次上采样都能看到当前层最强的边缘响应而不是像 FCN 那样通过加法融合。对汉字笔画来说起笔和收笔的渐变部分往往只有一两个像素UNet 的 skip connection 能更好保留这些位置。这里有一点容易忽略UNet 解码器第一层通道数通常较大如果输入图只有 256x256池化四次后特征图只有 16x16对汉字这种小尺寸目标可能过深。源码里 unet.py 如果默认是较宽的通道数我通常会把第一层通道数改小到 64 再训练否则小数据集特别容易过拟合。2.5 mynet 与四个模型怎么选mynet.py 是自定义实验网络具体结构由源码决定但它的存在意义是给不满足于固定网络的人一个修改位。不建议直接改 fcn.py 或 unet.py 来试错那会让模型文件失去和原论文的对应关系要加注意力机制或轻量化模块应该复制 mynet.py 再改。模型上采样方式特征融合适合场景FCN转置卷积/插值同层相加快速 baseline、接口兼容SegNet最大池化索引无跨层融合显存受限、稳定复现UNet转置卷积通道拼接细笔画、笔锋保留mynet自定义自定义结构改进实验选型策略上字库重建这类离线训练任务我更倾向 UNet如果做的是大规模多字体训练且显存吃紧SegNet 更稳妥。FCN 最大的价值是让你理解分割网络怎么从分类网络过渡过来所以建议至少完整跑一遍 train-fcn.py再进 UNet 调参。3. dataset.py 与训练脚本从像素标签到多模型训练3.1 标签图先做类别映射再谈 data loader字体分割和自然图像分割有一个明显差异标注文件往往不是整数索引而是用不同颜色表示的 PNG。dataset.py 里第一件事常是把 RGB 标签转换成整数 label。下面这段颜色映射代码是这类项目里最常见的写法import numpy as np # 标注软件导出的 RGB 颜色表 color2class { (0, 0, 0): 0, # 背景 (255, 255, 255): 1, # 主笔画 (255, 0, 0): 2, # 横折的角点或辅助笔画 } def rgb_to_label(rgb): h, w rgb.shape[:2] label np.zeros((h, w), dtypenp.uint8) for color, cls in color2class.items(): mask np.all(rgb np.array(color).reshape(1, 1, 3), axis2) label[mask] cls return label这里循环遍历颜色字典对每个颜色生成一个布尔 mask 再填充如果颜色表很大可以改用 numpy 向量化查表但笔划分割的颜色种类一般不超过 10 个循环足够。还要注意如果标注里存在空洞区域通常约定为 255这类像素要在训练脚本里设ignore_index255不能直接当成第 255 个类别训练。3.2 环境复现env.yaml 与训练入口拿到包后按 readme.txt 的说明建环境。如果 readme 没写清楚先在 env.yaml 里确认 conda 环境名标准做法是conda env create -f env.yaml conda activate font-seg python train300.py --data ./img --model unet --base-size 512 --epochs 300conda env create会根据 yaml 文件锁定 Python 和 PyTorch 版本--data指定图集目录--model选择模型--base-size控制训练分辨率。对字体笔划分割我一般先设 512因为 256 会让横折这类 2 像素宽的结构直接消失如果显存不够就把 batch-size 减半而不是继续降分辨率。train300.py 适合先跑通流程能帮助确认 dataset.py 的标签映射和损失函数没有方向性错误。这个脚本跑满 300 轮可能很久所以我通常把 train300.py 里的 epoch 数临时改小比如 20先观察 loss 是否下降、miou 是否从 0 开始上升确认无误后再改回 300。不要一开始就用完整数据集加 300 轮那是在浪费 GPU 时间。3.3 训练超参数表细笔画任务怎么调下面是针对字体笔划分割比较常用的参数取值范围。不同数据集的数值会有差异但调整方向是确定的。参数推荐范围为什么base-size512 或 448笔画宽度需要在像素级保留batch-size4~8多尺度特征图占显存learning-rate1e-4 ~ 3e-4预训练 encoder 不适合过大 lrnum-classes背景笔画类型数需要和 color2class 对应ignore-index255跳过标注空洞表格里的 lr 我习惯从 1e-4 起从 FCN 切到 UNet 时如果发现 loss 在 0.5 附近震荡先把 lr 降到 3e-5如果只是训练集不拟合才考虑调大 lr 或增加迭代轮次。batch-size 调节时要同步调整学习率否则梯度估计会变差。4. 评估链路miou、fwiou、mpa 和 drawImg.py 的配合4.1 三个指标分别解决什么问题很多分割项目只报一个 accuracy但在笔划分割中背景像素占比通常超过 90%accuracy 很容易做到 95% 以上模型却可能完全没有识别出笔画。所以源码里同时给出 miou.py、fwiou.py、mpa.py 三个指标脚本。Mean IoU 对每个类别的交并比取平均不受类别样本数影响FWIoU 在 IoU 基础上用每个类别的频率加权更贴近真实业务里背景占比高的情况MPA 是像素级平均准确率用来做快速参考。4.2 自己算 MIoU 和 FWIoU 时避开除零评估脚本的核心是混淆矩阵。一个直接计算 MIoU 的片段如下import numpy as np def compute_miou(pred, label, num_classes): ious [] for c in range(num_classes): p_mask pred c l_mask label c inter np.logical_and(p_mask, l_mask).sum() union np.logical_or(p_mask, l_mask).sum() if union 0: ious.append(float(nan)) # 该类别没有出现 else: ious.append(inter / union) return np.nanmean(ious)这段代码里最关键的是union 0的分支。当某张图里完全没有某个笔划类别时如果直接除零最终指标会变成 inf导致整次评估无效。处理方式有两种返回 nan 然后让 nanmean 忽略或者把该类别的 iou 记成 1表示“预测正确”。我建议用 nanmean 版本因为它能反映类别缺失的情况而不是人为假设缺失即正确。注意pred和label的 shape 都是 [H, W]每个像素存的是整数类别。4.3 countclass.py 和 drawImg.py 怎么用countclass.py 的作用是统计每个类别的像素占比。我在第一次跑笔划分割时会先执行它确认训练集里横、竖、撇、捺的比例如果某一类占比低于 1%那不管模型多好这类都学不干净。drawImg.py 则把预测结果和原图叠在一起输出用来定位“断开”和“粘连”这两种典型错误。脚本/指标关注点典型用法miou.py所有类别平均交并比模型间横向对比fwiou.py按频率加权的 IoU反映真实场景质量mpa.py像素平均准确率快速 sanity checkcountclass.py类别像素占比数据不平衡排查drawImg.py可视化预测笔锋断裂/粘连定位实际操作中我会在训练结束后先跑 miou.py再跑 fwiou.py如果两者差距超过 10 个百分点说明低频笔画类别表现差需要回看 dataset.py 和损失函数加权。此时 drawImg.py 输出的图里通常能看到细笔画被整体遗漏而不是边缘粗糙。另一种常见误用是每次迭代都跑全部指标白白浪费时间。正确做法是每 N 个 epoch 跑一次 miou.py在保存 checkpoint 时用 drawImg.py 导出当前预测样例。5. 预测脚本和笔划后处理从概率图到干净结果5.1 predict.py 的阈值不是固定值predict.py 把训练好的模型权重加载后对输入图像输出一个 [batch, num_classes, H, W] 的概率图。笔划分割的类别不平衡很严重所以直接取 argmax 未必是最好的选择通常先取笔划通道再做阈值分割。我推荐的阈值策略是在验证集上搜索固定阈值而不是永远用 0.5。一个简单做法是针对笔划通道扫 0.3 到 0.7用 miou 最大值的对应阈值作为最终推理阈值。5.2 用形态学过滤把预测结果变成可交付的笔画图最后一步处理对字库重建场景尤其重要。直接把概率图阈值化后经常会看到背景上的孤立噪点以及笔画中出现的 1~2 像素空洞。开运算可以同时处理这两种情况先腐蚀去掉小噪点再膨胀恢复笔画原有宽度。示例代码如下import cv2 import numpy as np # prob_map: [1, num_classes, H, W]从 predict.py 拿到 fg prob_map[0, 1] # 笔划类 binary (fg 0.5).astype(np.uint8) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) # 先开运算去噪再用连通域过滤掉小碎片 opened cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) num_labels, labels, stats, _ cv2.connectedComponentsWithStats(opened, connectivity8) keep np.zeros_like(opened) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] 5: # 小于5像素的杂点删除 keep[labels i] 1这里的cv2.CC_STAT_AREA阈值 5 需要根据实际图像分辨率调整如果原图是 1024x1024保留区域可以放宽到 10。运行后keep就是一张干净的 8 位掩码图可以直接作为字体笔划的输入轮廓。注意最后整理成 shape 为 [H, W] 的 uint8不要带 batch 维度方便后续的轮廓提取和矢量化工具接入。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价