用 MNN 推理两个模型,构建完整的文档 OCR Pipeline:
- PP-DocLayoutV3 — 版面检测:检测文本段落、标题、公式、插图、表格、印章等区域,并给出阅读顺序
- GLM-OCR-MNN — 文字识别:对版面检测裁剪出的区域进行 OCR 识别,输出文字内容
所有输入输出规格均经过实测验证(验证工具见 tools/ 目录 Python 脚本)。
glmocr_cpp/
├── CMakeLists.txt 构建脚本
├── src/ # C++ 源码
│ ├── layout.hpp / .cpp 版面检测: LayoutDetector (预处理+推理+后处理)
│ ├── loader.hpp / .cpp OCR 大模型接口 (GlmOcr, MNN LLM 进程内封装)
│ ├── formatter.hpp / .cpp 结果格式化 (去重/标题/公式/markdown/json)
│ ├── crop_scaler.hpp / .cpp 裁剪缩放 (按 label 的宽高 scale, tiers 分档)
│ ├── label_router.hpp 25 类标签唯一路由表 (task/标准名)
│ ├── log.hpp 日志模块 (分级日志 + 分层耗时树)
│ ├── pipeline.cpp 完整流水线入口 (编排)
│ └── main.cpp 命令行 demo (layout_demo)
├── tools/ # Python 验证/标定工具
│ ├── verify_postprocess.py 后处理一致性验证 (C++ vs Python)
│ ├── inspect_mnn.py 读取 .mnn 元信息 (输入输出名/shape)
│ └── probe_mnn_run.py 造合成图验证输出格式
├── config/
│ └── scale_config.example.json 缩放配置示例 (JSON)
├── doc/pipeline.md 处理逻辑/使用说明/关键模块/扩展路线
├── models/ 模型 (gitignored, 从 ModelScope 下载)
│ ├── PP-DocLayoutV3.mnn 版面模型 (PP-DocLayoutV3_onnx 经 MNNConvert)
│ └── GLM-OCR-MNN -> ... GLM-OCR 4bit 模型 (符号链接)
├── img/test.png 测试图 (数学试卷, 实为 WebP 格式)
├── mnn_lib/ MNN 库 (gitignored, 从 MNN 源码编译)
└── build/ 编译产物 (gitignored)
| 名称 | shape | 含义 |
|---|---|---|
image |
[1, 3, 800, 800] |
预处理后的图:RGB、拉伸resize 到 800×800(bicubic,不保宽高比)、÷255、HWC→CHW |
im_shape |
[1, 2] |
原图尺寸 (h, w) |
scale_factor |
[1, 2] |
缩放比 (h/800, w/800) |
| 名称 | shape | 含义 |
|---|---|---|
fetch_name_0 |
[300, 7] float32 |
每行 (label_id, score, x1, y1, x2, y2, order);坐标在 800×800 输入坐标系;图内不做阈值和 NMS,300 行全量输出 |
fetch_name_1 |
[1] |
常量 300(top-k),无实际用途 |
fetch_name_2 |
[300, 200, 200] int32 |
每个 query 的 1/4 分辨率 mask(提取 polygon 用),bbox 方案下忽略 |
| id | 类别 | id | 类别 | id | 类别 |
|---|---|---|---|---|---|
| 0 | abstract | 9 | footer_image | 18 | reference |
| 1 | algorithm | 10 | footnote | 19 | reference_content |
| 2 | aside_text | 11 | formula_number | 20 | seal |
| 3 | chart | 12 | header | 21 | table |
| 4 | content | 13 | header_image | 22 | text |
| 5 | display_formula | 14 | image | 23 | vertical_text |
| 6 | doc_title | 15 | inline_formula | 24 | vision_footnote |
| 7 | figure_title | 16 | number | ||
| 8 | footer | 17 | paragraph_title |
C++ 中用 layout_label_name(id) 查询(layout.hpp)。
GLM-OCR 是一个视觉语言模型(VLM),用于文档图像的文字识别。它通过 MNN 的 LLM 引擎 (llm_demo) 运行,不是直接喂 tensor,而是通过 prompt 格式驱动。
| 文件 | 大小 | 说明 |
|---|---|---|
config.json |
342B | 运行时配置(后端类型、线程数、采样器等) |
llm_config.json |
884B | 模型架构参数(hidden_size、image_mean/norm、chat template) |
llm.mnn |
486KB | LLM 主干网络图 |
llm.mnn.weight |
365MB | LLM 权重(4bit 量化) |
visual.mnn |
516KB | 视觉编码器图 |
visual.mnn.weight |
462MB | 视觉编码器权重 |
embeddings_bf16.bin |
182MB | 词嵌入权重(bf16) |
tokenizer.txt |
592KB | 分词器词表 |
| 参数 | 值 | 说明 |
|---|---|---|
model_type |
glm_ocr |
模型类型标识 |
hidden_size |
1536 | 隐层维度 |
image_size |
420 | 视觉编码器输入尺寸(正方形,420×420) |
is_mrope |
true | 使用 M-RoPE(多维旋转位置编码) |
is_visual |
true | 包含视觉编码器 |
image_mean |
[122.77, 116.75, 104.09] | 图像归一化均值(R/G/B) |
image_norm |
[0.01460, 0.01501, 0.01422] | 图像归一化标准差倒数 |
vision_start |
59256 | 图像起始 token id |
vision_end |
59257 | 图像结束 token id |
image_pad |
59280 | 图像填充 token id |
<img>图片路径</img>类别标签 Recognition:
<img>...</img>: MNN Omni 引擎的图像标签,引擎自动解析路径并调用视觉编码器- 类别标签: 由上游 layout 检测提供(如
Text、Table、Formula、Image) Recognition:: 固定后缀,触发模型进行文字识别
示例:
<img>/path/to/cropped_region.jpg</img>Text Recognition:
| prompt | 输出格式 | 实测样例 |
|---|---|---|
Text Recognition: |
纯文本(含题目选项) | 试卷第1题完整文字 ✓ |
Table Recognition: |
HTML <table>...</table> |
examples/source/table.png → 完整表格 HTML ✓ |
Formula Recognition: |
LaTeX,$$...$$ 包裹 |
$$x^{2}+10x=25$$ ✓ |
- 多
<img>标签:引擎支持多图编码(tokens 增长),但模型无法区分多图归属, 输出只覆盖其中一张 ⇒ 逐区域单图调用,与 Python SDK 行为一致。 ⚠️ 静默失败模式:MNN_BUILD_LLM_OMNI=OFF编译出的 llm_demo 遇<img>不报错、 静默当纯文本(prompt tokens 仅 ~10、vision time 0.00)。重新编译后恢复正常。 每次换 MNN 版本/重编后,必须复跑上面的 prompt 验收。
必须开启 OMNI 支持(视觉编码器):
cmake .. -DMNN_LOW_MEMORY=ON -DMNN_BUILD_LLM=ON -DMNN_BUILD_LLM_OMNI=ON -DMNN_SUPPORT_TRANSFORMER_FUSE=ON
make -j如果 MNN_BUILD_LLM_OMNI=OFF,vision time 将为 0.00s,图像不会被处理。
| 指标 | 值 | 说明 |
|---|---|---|
| 视觉编码 | ~11s / 0.55MP | 对 1789×300 裁剪区域 |
| Prefill 速度 | ~369 tok/s | prompt token 预填充 |
| Decode 速度 | ~55 tok/s | 文本生成 |
| Prompt tokens | ~716 | 包含图像 token + 文本 token |
| 首次加载 | ~6.5s | 模型加载+tuning |
# 通过 llm_demo 运行
cd /path/to/mnn/build
./llm_demo /path/to/GLM-OCR-MNN/config.json prompt.txt
# prompt.txt 内容:
# <img>/path/to/image.jpg</img>Text Recognition:依赖:MNN ≥ 2.0(C++ 库)、OpenCV(core/imgproc/imgcodecs)、CMake ≥ 3.16、C++17。
本目录已自带 MNN 库:mnn_lib/(lib/libMNN.so + include/,来自 WSL
/home/dr/mnn_build/MNN 的编译产物,Linux x86_64)。直接用它编译:
cd glmocr_cpp
cmake -B build -DMNN_ROOT=$PWD/mnn_lib -DCMAKE_BUILD_TYPE=Release
cmake --build build -j 8
# 运行时自动从 mnn_lib/lib 找 libMNN.so (rpath 已设置), 无需 LD_LIBRARY_PATH换平台(如 Windows/MSVC)时,把 mnn_lib/ 换成对应平台的 MNN 库即可:
# Linux 全局安装的 MNN
cmake -B build -DMNN_ROOT=/usr/local/mnn -DOpenCV_DIR=<opencv构建目录>
# Windows (MSVC)
cmake -B build -DMNN_ROOT=C:/libs/mnn
cmake --build build --config ReleaseMNN 源码编译(需要自己编时):
git clone https://github.com/alibaba/MNN.git && cd MNN
mkdir build && cd build
cmake .. -DMNN_BUILD_CONVERTER=OFF -DMNN_BUILD_DEMO=OFF
make -j8 && cmake --install . --prefix /usr/local/mnn# 基本用法: layout_demo <model.mnn> <图片路径> [可视化输出路径]
./layout_demo ../models/PP-DocLayoutV3.mnn ../img/test.png
# 指定可视化保存路径(不指定则自动存为 <图片路径>_vis.jpg)
./layout_demo ../models/PP-DocLayoutV3.mnn ../img/test.png vis.jpg./build/pipeline models/PP-DocLayoutV3.mnn /mnt/d/mnn/models/GLM-OCR-MNN/config.json <image> [output.txt]流程与任务路由(对齐 glmocr SDK label_task_mapping):
| 任务 | 类别 | 行为 |
|---|---|---|
| Text | abstract/algorithm/content/doc_title/figure_title/paragraph_title/reference_content/text/vertical_text/vision_footnote/seal/formula_number | "Text Recognition:" → 纯文本 |
| Table | table | "Table Recognition:" → HTML |
| Formula | display_formula/inline_formula | "Formula Recognition:" → LaTeX |
| Skip | chart/image | 只保存裁剪图 imgs/page0_idx*.jpg,markdown 中以  引用 |
| Abandon | header/footer/header_image/footer_image/footnote/number/aside_text/reference | 直接丢弃 |
实现位置:src/loader.cpp(ocr_task_type 路由 + GlmOcr 进程内封装)、src/pipeline.cpp(串线)、src/formatter.cpp(结果格式化)、src/label_router.hpp(唯一路由表)。
指定输出文件时生成三个产物:result.txt(调试用逐区域明细)、result.md(最终 markdown)、result.json(结构化结果)。
- 标题:doc_title →
#,paragraph_title →##(先剥掉 OCR 输出里自带的#/-/*) - 公式:重排为
$$\n...\n$$独立块 - 文本:
·/•/*→-列表;(A)xxx→(A) xxx、1.xxx/3)xxx→1. xxx/3) xxx;单换行 → 双换行(markdown 段落) - 重复标点折叠:
.....→...(对齐_clean_content) - 跨区域内容去重(C++ 增强,Python 版没有):重叠框把同一段文字识别两次时只保留一份。 规则 A:同为 text 且空间重叠(交/较小框面积)≥0.4 且归一化前缀 LCS 相似度 ≥0.65 → 丢低分者; 规则 B:归一化后短文本(≥24 码点)是长文本子串 → 丢短者。 实测 test.png 中"第4题"被两个部分重叠的 text 框(order=56/57)各识别一次,去重后只保留高分框。
- JSON:
[{index, label(text/table/formula/image), content, bbox_2d(原图像素), order, image_path?}]
# C++ 跑检测并 dump 原始 [300,7] 输出 (main 已默认 dump 到 /tmp/fetch_name_0.txt)
./build/layout_demo models/PP-DocLayoutV3.mnn img/test.png
# Python 端用同一份原始输出复现后处理链, diff 应完全一致
python3 tools/verify_postprocess.py --raw /tmp/fetch_name_0.txt 2381x3367已验证 4 张图(test/paper/table/seal)C++ 与 Python 后处理输出逐框完全一致。
后处理链(对齐 layout_postprocess_utils.apply_layout_postprocess):
score 过滤 → NMS(0.6/0.98) → 整页 image 过滤(0.82/0.93, 全滤光时不清空)
→ 嵌套框合并(全类 large, reference=small, preserve=image/seal/chart,
is_contained 交面积/自身面积≥0.8 不带+1) → stable 按 order 排序
→ unclip(默认 1.0,1.0 可调) → clamp + 无效剔除。unclip+clamp 后的框即 OCR 裁剪框。
注意:C++(OpenCV INTER_CUBIC) 与 Python(PIL BICUBIC) 预处理 resize 有细微插值差异,
会导致检测坐标/order 有 1-2px 级漂移——这是推理输入差异,非后处理逻辑差异;
严格对比请用 --raw 模式喂同一份原始输出。
# 基本用法: layout_demo <model.mnn> <图片路径> [可视化输出路径]
./layout_demo ../models/PP-DocLayoutV3.mnn ../img/test.png
# 指定可视化保存路径(不指定则自动存为 <图片路径>_vis.jpg)
./layout_demo ../models/PP-DocLayoutV3.mnn ../img/test.png vis.jpg输出示例:
原图: 2381 x 3367
推理耗时: 743 ms (CPU)
检测到 56 个区域 (按阅读顺序):
doc_title score=0.925 [ 454.2, 110.4, 1916.9, 181.8] order=1
paragraph_title score=0.871 [ 130.2, 212.0, 872.7, 267.0] order=2
text score=0.952 [ 132.9, 277.8, 1923.8, 577.6] order=3
...
可视化已保存: ../img/test.png_vis.jpg
框颜色含义(可视化):蓝=text、红=image、绿=table、紫=seal、橙=其余类别。
#include "layout.hpp"
cv::Mat bgr = cv::imread("doc.png"); // 支持 jpg/png/webp(视 OpenCV 编译选项)
LayoutDetector det;
det.loadModel("models/PP-DocLayoutV3.mnn", /*num_threads=*/4);
det.setScoreThreshold(0.3f); // 可选
det.setDebug(false); // true 时 dump 模型输出
std::vector<LayoutBox> boxes;
det.detect(bgr, &boxes); // 可循环调用, 模型只加载一次
for (const auto& b : boxes) { // 已按阅读顺序排序
// b.label_id / layout_label_name(b.label_id)
// b.score, b.x1, b.y1, b.x2, b.y2 (原图像素坐标, 已裁剪到图边界)
// b.order (阅读顺序)
}内部数据流(与 Python 版 glmocr 对齐):
BGR→RGB → 拉伸800×800(bicubic) → ÷255 → CHW
+ im_shape=(h,w), scale_factor=(h/800, w/800)
→ MNN 推理 → fetch_name_0 [300,7]
→ score≥阈值过滤 → 坐标×(w/800, h/800)回原图 → 裁剪到边界
→ NMS(同类0.6/异类0.98, 与 glmocr layout_postprocess_utils 一致)
→ 按 order 升序(阅读顺序)输出
| 参数 | 默认值 | 位置 | 说明 |
|---|---|---|---|
| 置信度阈值 | 0.3 |
det.setScoreThreshold() |
调高→框更少更准;调低→召回更多。Python 版 glmocr 用 0.3,PaddleX 可视化默认 0.5 |
| NMS IoU(同类) | 0.6 |
layout.cpp nms() 内 0.6f |
同类框 IoU≥此值被抑制,与 Python 版一致 |
| NMS IoU(异类) | 0.98 |
同上 0.98f |
异类几乎不互相抑制(允许同区域多标签竞争,靠 score 取胜) |
| 输入尺寸 | 800 |
layout.cpp kInputSize |
模型固定 800×800,勿改(模型是固定 shape 导出的) |
| 线程数 | 4 |
det.loadModel(path, n) |
CPU 推理线程数,按机器核心数调 |
| 推理后端 | CPU | layout.cpp MNN_FORWARD_CPU |
GPU 改 MNN_FORWARD_OPENCL(移动GPU)/ MNN_FORWARD_CUDA(N卡),需对应编译 MNN 后端 |
| Debug dump | 关 | det.setDebug(true) |
打印 fetch_name_0 的 shape 和前 12 个值,排查模型/换模型时用 |
- 56 个区域:1 doc_title / 2 paragraph_title / 10 text / 27 inline_formula / 11 image / 2 vision_footnote 等
- 全部框准确贴合;阅读顺序 order 1→292 严格单调
- 耗时 743 ms(WSL CPU);同一逻辑 Python 端验证脚本
test_image.py可随时复跑
- 图内无阈值/NMS:模型输出 300 行全量结果(同一区域可能以不同 label 重复出现), 过滤和 NMS 全在 C++ 侧完成,改阈值不会触发重新编译模型。
- 坐标换算:模型输出坐标在 800×800 坐标系,
detect()内已完成回原图换算, 拿到的LayoutBox直接是原图像素坐标。 - PDF 不支持:本模块只收图片。PDF 需先自行渲染成图(如 pdfium/MuPDF)。
- WebP:测试图实际是 WebP。OpenCV 需带 WebP 支持(pip/官方预编译版一般都有),
否则
cv::imread返回空。 - fetch_name_2(mask):需要 polygon 级精细轮廓时才用(mask → findContours → 多边形), 当前实现忽略它,区域裁剪用矩形框已足够。
- 换模型时:先用
inspect_mnn.py确认输入输出名和 shape, 再用probe_mnn_run.py/test_image.py验证输出格式是否仍是[300,7]布局。