把 PP-OCRv6 的检测和识别模型包成了一个纯 Go 库:github.com/lib-x/ppocr-v6-go 。用 pure-onnx 通过 purego 动态加载 ONNX Runtime,不需要 CGO,不用装 PaddlePaddle 和 OpenCV,除了两个模型文件,只有一个内嵌的字符字典。
代码量不大,时间主要花在把模型需要的参数一个个找出来,再验证对。这篇按"模型和文件从哪来、拿到新模型怎么入手、一张图怎么走完全流程"的顺序记录,中间有四个坑单独讲。
先看术语:检测、识别和它们背后的东西
整图 OCR 一般拆成两个模型。检测模型在整页图上找文字行,输出一堆框,它不认字;识别模型把每个框裁出来读成字符串,它不找位置。PP-OCRv6 是 PaddleOCR 的 v6 版本,det 和 rec 各有 medium / small / tiny 三档,本库用的是 medium det(约 62MB)和 small rec(约 21MB)。本库的组合是 medium det 加 small rec。模型卡表格里 medium 档检测 Hmean 是 86.2%、medium rec 识别准确率 83.2%,small rec 是 81.3%,论文在 arxiv 2606.13108 。这些是模型卡在标准测试集上的数字,和你手上某张图的端到端准确率是两回事。
模型以 ONNX 格式分发。ONNX 文件里除了权重,还存着一张计算图:节点怎么连、每个张量叫什么、形状是什么。ONNX Runtime 执行这张图,pure-onnx 用 purego 加载它的共享库,Go 侧就能直接跑推理。
下面这些词后面会反复出现,先把它们和公式放在一起:
| 术语 | 含义 | 公式或规则 |
|---|---|---|
| 概率图 | 检测模型的输出,每个像素一个 0 到 1 的值,表示"这里是文字"的概率 | 输出尺寸等于输入尺寸,见下文反卷积 |
| 二值化 | 把概率图按阈值切成黑白两色 | p > thresh 记为前景 |
| 连通域 | 把相邻的前景像素归成一个块,一个块对应一个候选文本框 | 8 邻域并查集 |
| shrink / unclip | 训练时把文字区域缩小后再让模型学,推理时按同样规则放大回来 | 见下方 DB 公式 |
| CTC | 一种不需要字符级对齐的训练目标,识别模型靠它把变长序列映射到文本 | 见下方解码规则 |
| blank | CTC 的占位类别,输出里占一个类别位,解码时丢弃 | 类别 0 |
| 时间步 T | 识别模型输出的序列长度,每个时间步给出一个类别分布 | 实测 $T = \lfloor (W+3)/8 \rfloor$,约等于 W/8 |
| 字典 | 类别编号到字符的映射表 | 18708 个字符,加 blank 和空格共 18710 类 |
| softmax | 把一组实数变成和为 1 的概率分布 | $p_i = \exp(z_i) / \sum_j \exp(z_j)$ |
| 归一化 | 把像素值缩放到模型训练时的数值范围 | $x’ = (x/255 - \mu)/\sigma$ |
| NCHW | 张量内存布局:批、通道、高、宽 | 模型输入是 [1, 3, H, W] |
| Hmean | 检测任务的综合指标,精确率和召回率的调和平均 | $H = 2PR/(P+R)$ |
| 置信度 | 本库里是解码时每个字符最大概率的平均值 | 越高越可信,0.9 以上基本没问题 |
DB(检测用的算法)的两个公式。训练时文字区域按比例缩小,缩小的偏移量是
其中 $A$ 是区域面积、$L$ 是周长、$r$ 是 shrink 比例(PP-OCRv6 的检测配置里是 0.4,意思是面积收缩到原来的 $r^2$)。推理时反过来,把预测出来的区域放大,这一步叫 unclip,用的是另一个公式
$u$ 就是 unclip 比例,取模型契约里的 1.4。两个公式长得像,但不是互逆关系:$r$ 和 $u$ 是不同的量,各自是经验值。
CTC 的解码规则:每个时间步取概率最大的类别,丢掉 blank,合并连续重复。比如输出序列是 剁 剁 blank 椒 blank 鱼 鱼 头,解码就是"剁椒鱼头"。这套规则来自 CTC 原论文
(Graves 等,ICML 2006);检测的 DB 算法来自 Real-time Scene Text Detection with Differentiable Binarization
(Liao 等,AAAI 2020)。
模型和配套文件从哪来
模型下载(都是 Apache-2.0):
| 用途 | 地址(仓库) | 要下的文件 | 大小 |
|---|---|---|---|
| 识别 | ModelScope: PP-OCRv6_small_rec_onnx | inference.onnx |
21,159,378 字节 |
| 识别 | 同上 | inference.yml |
约 75KB(里面内嵌了整份字典) |
| 检测 | HuggingFace: PP-OCRv6_medium_det_onnx | inference.onnx |
62,032,837 字节 |
| 检测 | 同上 | inference.yml |
约 2KB |
下载完先对大小,HuggingFace 的响应头里直接有 x-linked-size,ModelScope 看文件大小即可;要更严格就记 sha256。文件名两个都叫 inference.onnx,放一起时记得改名(库里是 inference.onnx 和 det-v6.onnx)。
别漏了同一个目录下的 inference.yml。 它是随模型分发的部署契约,写明这个模型该怎么预处理、怎么后处理,比博客、比训练配置、比任何人的记忆都权威。检测模型那份去掉字典后长这样:
PreProcess:
transform_ops:
- DecodeImage: {channel_first: false, img_mode: BGR}
- DetResizeForTest: null
- NormalizeImage: {scale: 1./255., mean: [0.485, 0.456, 0.406], std: [0.229, 0.224, 0.225], order: hwc}
- ToCHWImage: null
PostProcess:
name: DBPostProcess
thresh: 0.2
box_thresh: 0.45
unclip_ratio: 1.4
max_candidates: 3000
识别模型那份的 PreProcess 写的是 DecodeImage: {img_mode: BGR}、RecResizeImg: {image_shape: [3, 48, 320]},PostProcess 是 CTCLabelDecode 并内嵌了整份字符字典(所以文件大)。这几行直接决定了库里 Config.RGB、DetConfig.Threshold、BoxThresh、UnclipRatio 的默认值,也决定了解码时类别编号怎么映射。
ONNX 文件之外,还需要看四个地方,它们的内容和 Go 里的落点一一对应:
| 文件 | 关键内容 | Go 里对应什么 |
|---|---|---|
configs/rec/PP-OCRv6/PP-OCRv6_small_rec.yml |
d2s_train_image_shape: [3,48,320]、character_dict_path、use_space_char: true、PostProcess: CTCLabelDecode |
Config.Height 默认 48;字典文件路径;空格类别;解码方式 |
configs/det/PP-OCRv6/PP-OCRv6_medium_det.yml |
algorithm: DB、MakeShrinkMap: shrink_ratio 0.4、NormalizeImage 的 ImageNet 均值方差、DBPostProcess: thresh 0.2 / box_thresh 0.45 / unclip_ratio 1.4 |
DetConfig.Threshold、UnclipRatio;检测归一化的固定均值方差 |
ppocr/utils/dict/ppocrv6_dict.txt |
18708 行,一行一个字符 | go:embed 进二进制的字典,类别 1..18708 |
模型仓库里的 inference.yml |
部署契约:DecodeImage.img_mode: BGR、RecResizeImg.image_shape: [3,48,320]、检测的 DBPostProcess: thresh 0.2 / box_thresh 0.45 / unclip_ratio 1.4、rec 的 PostProcess 还内嵌了整个字符字典 |
Config 的通道顺序、DetConfig 的阈值与 unclip 比例、字典交叉校验 |
第三列不是抄一遍配置就完事:同一批参数在官方各处并不一致。以检测后处理为例,模型契约(inference.yml)写的是 0.2 / 0.45 / 1.4,老版推理命令行是 0.3 / 0.6 / 1.5,PaddleX 的兜底默认又是 0.3 / 0.6 / 2.0。三套都记下来,默认跟模型契约,因为那是随这份模型文件一起发布的;换模型时先读它,再去源码里找解释。
拿到一个没见过的 ONNX 模型,怎么入手
换一个模型要回答的问题是一样的:张量叫什么、什么形状、什么数据类型、预处理从哪抄、输出是什么语义、动态形状怎么拿。按顺序五步,每步都带上"怎么判断这步做对了"。
第一步,把图读全,不只是输入输出。 用 Python 的 onnx 包:
import onnx
m = onnx.load("model.onnx", load_external_data=False)
g = m.graph
print("opset:", [(o.domain or "ai.onnx", o.version) for o in m.opset_import])
print("nodes:", len(g.node), "initializers:", len(g.initializer))
for v in list(g.input) + list(g.output):
dims = [d.dim_value if d.HasField("dim_value") else d.dim_param
for d in v.type.tensor_type.shape.dim]
print(v.name, dims)
for n in list(g.node[:3]) + list(g.node[-3:]):
print(n.op_type, n.name)
本库两个模型的输入输出:
# 识别
x ['DynamicDimension.0', 3, 48, 'DynamicDimension.1']
fetch_name_0 ['DynamicDimension.0', 'Reshape_471_o0__d2', 18710]
# 检测
x ['DynamicDimension.0', 3, 'DynamicDimension.1', 'DynamicDimension.2']
fetch_name_0 ['ConvTranspose_592_o0__d0', 1, 'ConvTranspose_592_o0__d2', 'ConvTranspose_592_o0__d3']
要看的东西:末端算子决定输出语义,最后是 Softmax 或 Sigmoid 说明输出已经是概率,是 MatMul、Gemm 多半是 logits;开头几个算子出现 Sub、Div、Mul 的组合,归一化可能已经烧进图里,预处理就不能再来一遍;elem_type 是 2(uint8)或 3(int8)、或者图里出现 QuantizeLinear、DequantizeLinear,说明是量化模型,预处理要和量化校准时一致,scale 和 zero_point 一般在 QDQ 节点里;输出不止一个时,先弄清每个输出的语义再动手:检测模型常常同时给框、分数、类别或掩码,这些都是必需结果;训练用的辅助头确实存在,但不能默认把多出来的输出当辅助头丢掉,得对着模型卡和部署代码确认。
判断标准:输入输出数量和形状能对上文档。动态维的名字里带什么不重要,DynamicDimension.1 只是导出工具起的名字,不能拿来判断语义,只能说明这一维在运行时确定。检测模型的输出维度名直接引用 ConvTranspose_592 节点,说明这一维来自反卷积层(真正决定尺寸的是它的 stride、kernel 和 padding)。至于到底等于多少,不靠推理靠实测:在库产生的 32 倍数尺寸上,输出尺寸都等于输入。
第二步,预处理去部署契约里抄,别抄训练配置。 最权威的是模型自带的 inference.yml:它写明了 DecodeImage.img_mode: BGR、检测的 ImageNet 均值方差、识别的 image_shape: [3,48,320],还有后处理参数。识别侧的归一化 (pixel/255 - 0.5) / 0.5 在部署代码里(RecResizeImg 之后的那步)。
判断标准:找一张 golden 图跑通。testdata/test3.png 是一行 669x45 的截图,识别结果和图上文字完全一致,置信度 0.9825;归一化数值或者缩放算错,这个测试就过不去。通道顺序它区分不出来(测试图接近灰度,RGB 和 BGR 只差 0.0003),所以这类参数只能去契约文件里找证据,别靠分数猜。
第三步,输出语义先验证,再写解码。 打印输出的一行,看取值范围和行和:接近 1 说明是概率,有负数或者和远小于 1 说明是 logits。识别模型的输出末端有 Softmax 节点,所以拿到手就是概率,解码前再 softmax 一次会把分布抹平:argmax 不变所以文字还对,置信度会从 0.98 掉到 0.0001。
第四步,动态形状:优先让运行时自己分配,拿不到再探测。 先澄清一个容易被带偏的说法:ONNX Runtime 本身不要求调用方预分配输出。它的 C API 写明输出数组可以传 nullptr,由运行时分配对象并回填(onnxruntime_c_api.h 里 Run 的注释),Python 的 session.run(None, feeds) 就是这么用的。必须预分配、形状还得精确匹配,是 pure-onnx 这个 Go binding 的接口限制。
所以顺序是:binding 支持运行时分配就直接用,跑完查实际形状;不支持才退回探测这种 workaround。本库走的是后一条:拿一个故意不对的输出张量跑一次,从报错信息里解析真实形状。识别输出 [1, T, 18710] 里的 T 和输入宽度有关,实测 714 宽对应 89 帧、320 宽对应 40 帧,约等于宽度的八分之一(扫了 43 个宽度,全部满足 $T = \lfloor (W+3)/8 \rfloor$,也就是 W/8 上下差 1,这正是不能套公式的原因);检测输出实测等于输入尺寸。这里有个陷阱:识别图会拒绝偏大的张量(报错信息里带着期望形状),检测图会静默接受偏大的张量,只把数据写进缓冲区前段,必须用故意过小的 [1, 1, 1, 1] 才能逼出错误。两种都试一次,哪边报错用哪边,结果按输入尺寸缓存。
第五步,golden 输入定生死。 一张覆盖预处理全链路的图跑通之前,不要往下做别的。这张图要能暴露归一化、缩放、通道顺序的问题,最好带颜色和极端长宽比。
一张菜单图走完全流程
拿 testdata/menu.png(970x1417)走一遍,每步的输入从哪来、输出去哪、怎么判断对不对。
检测:原图进,概率图出
原图先等比缩放,长边到 960,得到 657x960,再向右向下补白到 32 的倍数,变成 672x960(32 是检测网络的下采样步长)。补白用白色,白底黑字的文本区域不受影响。然后按 ImageNet 的均值方差归一化成 [1, 3, 960, 672] 的 float32 张量,通道顺序 BGR(模型自带的 inference.yml 写的就是 BGR)。
模型输出的是一张 960x672 的概率图,和输入同尺寸。把它存成灰度图长这样(生成代码在库仓库的 det_figures_test.go):

白色条带就是模型认为有文字的位置。判断检测这一步对不对,看这张图就够了:文字应该连成整条,虚线、边框、插图不应该亮起来。如果整张图发灰或者条带断裂,多半是归一化或者缩放的问题,不用往下看识别。
从概率图到框:二值化、连通域、unclip、映射
概率图先按阈值 0.2 二值化,再用 8 邻域连通域把相邻像素归成块,面积小于 10 像素的丢掉,每个块的外接矩形就是候选框。
这里要说清楚:库里的后处理是针对水平文本的简化实现。官方那套用 cv2.findContours 找轮廓、minAreaRect 取旋转框、按多边形做 unclip,本库用的是轴对齐矩形加连通域。参数名相同(thresh、box_thresh、unclip_ratio)但语义不完全等价,所以这套规则只适合横排文字;换成旋转文本、竖排、多栏混排,或者换一个检测模型,都要从那个模型自带的 decoder 重新开始,不能直接套这里的经验。同一行的相邻框按"垂直重叠超过较短框一半、水平间隙不超过框高"合并成整行。这条规则的依据是:同一行内部被空格、点线切开的距离通常小于行高,而相邻两行之间、左右两列之间的距离通常大于行高,用行高当间隙阈值正好把两种情况分开。然后按 DB 的规则 unclip,把训练时缩小的区域放大回来,比例取模型契约里的 1.4。最后把框从缩放坐标映射回原图:缩放系数是 970/657 = 1.476(原始像素除以缩放像素),乘上去,再夹到图像范围内。
菜单图这一步得到 34 个框,画到原图上:

标题、菜名、虚线、价格都在框内,食物照片和花边没有误框。判断标准有三条:数量对不对(菜单 34 行),位置对不对(框住整行,不切字),以及所有框的宽高必须是正数、坐标必须在图像范围内。最后这条不是废话,这次加这条检查时发现检测器返回了 112 个框,其中 78 个是 MinX: 991, MaxX: 3 这种反着的空框,根因在连通域实现里,见后面的坑。
识别:框进,文本出
每个框从原图裁出来(库里是精确裁剪,不加边距),等比缩放到高 48,宽度取 $\lceil 48w/h \rceil$。菜名那一行的框是 326x45,缩放后 348x48,归一化成 [1, 3, 48, 348],模型输出 [1, 43, 18710](43 ≈ 348/8)。输出已经是概率,每个时间步取 argmax,丢 blank、合并重复,类别 1..18708 查字典,18709 是空格,得到 剁椒鱼头……58元,置信度 0.9193。
判断标准:置信度只能当参考。整张菜单 34 行的分数落在 0.8556 到 0.9999,平均 0.9463;分数是解码时每个字符最大概率的平均值,不是校准过的正确率,模型完全可以高分输出错字。所以它只适合排序和设置"待人工复核"的阈值,真正的正确率要用带标注的样本算(识别看完全匹配率和字符错误率 CER,检测看框的 IoU、precision、recall)。手头没有标注时,退而求其次的办法是像前面那样把框画出来人工看一遍,加上 golden 文本断言。
各阶段的输入输出和判断方法汇总:
| 阶段 | 输入来源 | 输出去向 | 判断方法 |
|---|---|---|---|
| 检测预处理 | 原图 | [1,3,960,672] 张量 |
尺寸、补白位置、通道顺序;长宽必须是 32 的倍数 |
| 检测推理 | 上面的张量 | 960x672 概率图 | 概率图存图看,条带是否清晰 |
| 后处理 | 概率图 | 34 个框 | 数量、位置、宽高为正、在界内 |
| 裁剪与识别预处理 | 框 + 原图 | [1,3,48,W] 张量 |
宽高比、是否切字 |
| 识别推理与解码 | 上面的张量 | 文本 + 置信度 | 分数、golden 文本比对 |
这些数为什么是这么定的
没接触过视觉的话,可以先把这些系数归成三类,理解起来会顺很多:
- 过滤器:阈值、最小面积、最小边长。作用等同 SQL 里的 WHERE 条件,把"不像文字"的像素和区域丢掉,不改变模型本身
- 规格参数:长边 960、补白到 32 的倍数、识别高度 48、宽度按比例。作用是把任意尺寸的图规整成模型能吃的规格,类似把不同格式的请求体转成同一种 DTO
- 补偿参数:unclip 比例。训练时模型看到的是缩小过的文字区域,推理时要放大回来,这个比例就是补偿系数
逐个说。
概率图是什么。 检测模型的输出不是框,是一张和输入同样大的灰度图,每个像素的值在 0 到 1 之间,表示"这里像文字的程度"。前面那张黑白图就是它:白的地方是文字。理解这一点之后,阈值就好懂了。
二值化阈值 0.2。 概率图在文字边缘是渐变的,阈值取得高会把边缘切掉、行变短;0.2 偏向"宁可多留",把范围画大一点,多留出来的误检交给下一道过滤。这个值来自模型自带的 inference.yml(DBPostProcess: thresh: 0.2)。要说明的是,官方各条链路的默认值并不统一:模型契约是 0.2,老版推理命令行是 0.3,PaddleX 的兜底默认也是 0.3。这三个值都不是推导出来的,是各自链路调出来的经验值,没有公开的消融实验;换成自己调参时,得拿带标注的验证集去扫,别拿单张图当依据。
框级阈值 0.45。 二值化只保证"范围",不保证"可信度"。拿到一个候选框后,再算框内所有像素概率的平均值,低于阈值就整框丢掉。两道过滤的分工:像素级管范围,框级管可信度。这个值同样来自模型契约(box_thresh: 0.45;老版命令行和 PaddleX 兜底用的是 0.6,DBPostProcess 类自己的默认是 0.7,四套值并存)。菜单图里 34 个框的平均概率都高于这些值,所以阈值在 0.45 到 0.7 之间怎么取,这张图的结果都一样;噪点多的图上差别才明显。
最小面积 10 像素、最小边长 3 像素。 二值化之后总会有几个孤立的亮点(JPEG 压缩痕迹、纹理),它们面积小、凑不成文字行。这两个过滤就是按大小筛掉它们。官方用的是短边 3 像素,本库两个都做,效果接近。
unclip 比例 1.4。 这个参数最需要解释。训练时,标注的文字区域会先收缩一段再让模型学:收缩偏移量是 $D = A(1-r^2)/L$,其中 $r$ 是 shrink 比例(配置里取 0.4,意思是面积收缩到原来的 $r^2$),$A$ 是区域面积,$L$ 是周长。模型学到的就是这块收缩区域,推理时要把预测结果放大回去,否则每行首尾都会少一两个字。
放大用的公式和训练收缩不是同一个:$D’ = A \cdot u / L$,比例 $u$ 是个经验值。官方各链路给的值是 1.4(模型契约)、1.5(老命令行)、2.0(PaddleX 兜底),本库跟模型契约取 1.4。实测这个比例偏大代价明显:同一张菜单图,1.5 时 34 行的平均分 0.9489,2.0 时降到 0.9347,放大过头会让框吃进相邻内容。
长边 960。 官方给 PP-OCRv6 的部署配置是长边 960。为什么是这个数:它是速度与精度的折中,图越大耗时按面积增长。实测同一张 970x1417 的菜单图,长边取 480 / 960 / 1600 时检测耗时是 279ms / 638ms / 1796ms,逐级拉开;召回方面只有弱证据,把菜单缩到一半让字变小,1600 比 960 多检出 1 个框(35 对 34),原图上三者都是 34 个框,没有人工标注的情况下不能断言"更大就更准"。注意官方的语义是"超过 960 才缩小,小图保持原样",本库统一缩放到 960,小图会被放大:多花算力,换小字更多像素。
补白到 32 的倍数。 检测网络每 32 像素汇总成一个特征点(总下采样步长 32)。这不是"尺寸不整可能漏检"的小问题:实测把 500x300 直接喂给检测模型,ONNX Runtime 会在 Conv 节点上报特征图尺寸不匹配、直接失败(512x320 和 672x960 正常)。官方做法是把尺寸四舍五入到 32 的倍数(会轻微拉伸图像),本库改成向右下补白(白色,贴合白底文档),保持比例不变。
识别高度 48、宽度按比例。 模型是按高 48 训练的(配置里的 d2s_train_image_shape: [3,48,320]),改高度会破坏它学到的字形模式。宽度本库按 $\lceil 48w/h \rceil$ 等比缩放,但"压到固定宽度必然掉准确率"这个说法实测不成立:669 宽的一行压到 320 会丢一个空格、分数从 0.9822 掉到 0.9713,压到 480 分数反而升到 0.9870;菜单里两行压到 320,一行分数从 0.9602 升到 0.9900,另一行基本不动。一个可能的原因是训练时的宽度就在 320 一带(MultiScaleSampler 的 scales 是 320x32 / 320x48 / 320x64),长行等比缩放后反而离训练分布更远。库里的默认按比例,MaxWidth 留成可选项,需要时自己压。
归一化。 把像素值映射到训练时的数值范围,像给数据统一单位。识别侧是 (x/255 - 0.5) / 0.5,把 0 到 255 映射到 -1 到 1;检测侧用 ImageNet 的均值和方差。两边不能互换,用错了结果会明显变差。
CTC 的 blank 和空格。 字典 18708 个字符,模型输出 18710 类:类别 0 是 blank(占位符,解码时丢掉),1 到 18708 是字典字符,18709 是空格。这是训练配置 use_space_char: true 决定的,映射错一位整段就是乱码,所以库里构造时会校验类别数和字典是否匹配。
置信度取均值。 解码时每个字符都有一个概率,取这些概率的平均值作为整行分数,和官方 CTCLabelDecode 的做法一致。不用乘积是因为乘积会随文本长度指数衰减,长行的分数天然偏低,阈值就失去可比性。
完整版的参数表(含每个值的参考来源和改动后果)在库仓库的 docs/PARAMETERS.md 。
踩过的四个坑
双重 softmax。 第一版解码之后置信度一直上不去,文字是对的,分数只有 0.0001。原因是按常规做法在解码前又做了一次 softmax,而这个 ONNX 图的末端已经包含 Softmax 节点,再压一次把分布抹平了:argmax 不变所以文字还对,概率全被摊到 1/18710 附近。现在解码前先看行和,接近 1 就跳过 softmax。
缩放方向反了。 检测框映射回原图时,我把缩放系数乘反了:srcW / resizedW 是 1.476(原始像素除以缩放像素),应该乘 1.476,代码里乘的是它的倒数 0.677,框被缩到原点附近,一行菜名只框到前三个字,“剁椒鱼头"识别成"剁椒鱼”。定位方式是 dump 概率图,对比文字块的实际响应位置和提取出来的框位置,前者盖住整行,后者只盖住行首,乘错方向就很明显了。
通道顺序:查了三次才对。 这个坑值得完整讲,因为它演示了"看配置要看生效的那份"。
第一版代码按 BGR 喂入(RGB bool 零值是 false),理由来自训练配置里的 DecodeImage: img_mode: BGR。后来翻 PaddleX 的 predictor.py,看到构造函数里写的是 ReadImage(format="RGB"),以为找到了反证,就把默认改成了 RGB。
改完再核对时发现漏了一层:_build 会遍历模型配置里的 PreProcess.transform_ops,把 DecodeImage 生成的 reader 覆盖到同一个 “Read” 键上,也就是构造时的 RGB 只是兜底,真正生效的是配置里的 img_mode。最后在模型仓库自带的 inference.yml 里找到最终答案:两个模型都写着 img_mode: BGR。于是改回 BGR。
这三次里,测试始终没帮上忙:测试图接近灰度,RGB 和 BGR 的识别分数只差 0.0003,34 行的平均分 0.9478 对 0.9489。后来有人做了更细的对照,发现输出并非完全一致(同一行会多一个点、框尺寸有差别),只是聚合指标看不出来。结论:通道顺序这类契约参数,只能以生效的配置文件为准,不能靠测试分数反推。
连通域的编号有空洞。 并查集合并后,我把"最大的根标签值"当成了连通域个数,被合并掉的编号还留在 1..n 里,后处理给这些不存在的编号算出了 {w, h, 1, 1} 这种反着的空框,再经 unclip 和坐标映射,就变成了 78 个 MinX: 991, MaxX: 3 的幽灵框。pipeline 靠"裁剪失败就跳过"把它们吞掉了,所以端到端结果看起来正常,只有单独调 Detect 才会拿到 112 个框。修法是重新编号成 1..k,保证每个返回的编号都有像素,同时补了一条"所有框宽高必须为正"的回归测试。
封装和结果
所有参数集中在 Config(识别)和 DetConfig(检测)两个结构体里,默认值写在字段注释里。检测侧的张量名从图里直接读,识别侧的张量名和高度用模型对应的默认值(可以覆盖),类别数读出来校验字典,对不上在构造阶段就报错。填默认值和路径校验是两个函数,这里有个小插曲:一开始是一个函数,检测模块只想复用归一化的默认值,调了一次就报 ModelPath is required,因为校验把只填了一半的配置当成了用户配置。
整图 OCR 的接口就两行:
p, err := ppocrv6.NewPipeline(
ppocrv6.Config{ModelPath: "inference.onnx"},
ppocrv6.DetConfig{ModelPath: "det-v6.onnx"},
)
results, err := p.Recognize(img) // []LineResult{Box, Text, Score}
ONNX Runtime 的共享库通过环境变量 ONNXRUNTIME_LIB_PATH 指定,不设置时由 pure-onnx 自动下载到缓存目录(版本 1.24.1)。依赖上有个注意点:pure-onnx 目前要锁一个 commit 伪版本,v0.0.1 只有 Run(),没有后面用到的 RunWithValues。
菜单图的实测结果,34 行全部识别,置信度 0.8556 到 0.9999:
菜单 0.9974
特色菜 0.9999
锅仔 0.9996
剁椒鱼头……58元 0.9198
富贵毛血旺………58元 0.9552
川香水煮鱼……48元 0.9589
香椿拌黄花鱼……48元 0.9816
歌乐山辣子鸡……38元 0.9694
换模型档位要注意:同字典、同输入契约的可以只换文件;tiny 用的是另一个字典,换过去还要一起换 DictPath,类别数校验会把不匹配的组合拦在构造阶段。
小结
整个过程能复用的部分,按重要性排:先分清参数来源(图里能读的、框架配置和部署代码里查的、运行时探测的),再按"读图、抄预处理、验证输出语义、探动态形状、跑 golden 图"的顺序做,每一步都有可观察的判据(概率图、框的数量和位置、置信度、文本比对),对不上就停在那一步,不要带着不确定的假设往下走。
这次四个坑里有三个是"文档和实现不一致"造成的:训练配置和部署代码不一致、字段注释和字段默认值不一致、并查集返回值语义和调用方理解不一致。这类问题不会让程序崩溃,只会让结果悄悄变差,靠 golden 测试和边界检查(框宽高必须为正)才抓得住。
模型和参数细节整理在仓库的 docs/PARAMETERS.md ,示意图的生成代码和副本在 docs/ 。