☰
纯C++实现PP-OCRv5文字识别的全流程
2026/9/30 3:53:19 网站建设 项目流程

前言

PP-OCRv5 是 PaddleOCR 推出的新一代 OCR 模型系列,在中文、繁体、英文、日文的混合识别上相比前代有明显提升。但生产环境通常不想背 Python 生态:服务端要的是没有 Python 依赖、启动快、内存可控的 C++ 可执行程序。

本文讲这条路:把 PP-OCRv5 的检测模型和识别模型导出成 ONNX,用 ONNX Runtime 的 C++ API 走完"预处理 → 推理 → 后处理",自己实现 DB 检测框提取与 CTC 解码。

三点必须提前说明:


  1. PP-OCRv5 的模型结构、预处理参数、字典格式在不同版本间可能变化。本文给出的数值是社区实践中广泛使用的常用值,最终以 PaddleOCR 官方仓库的配置文件与文档为准。

  2. 本文不依赖 Paddle Inference。走 Paddle Inference 也是可行路线(省去转模型,但部署包更重),而预处理与后处理的逻辑完全一样。

  3. 代码聚焦流程正确性,省略工程化细节,但在该加的地方会指出。


一、两阶段流水线

PP-OCR 从来不是"一个模型搞定一切",而是流水线(pipeline),核心两级:


  1. 文本检测(Text Detection):输入整张图,输出若干文本区域的位置,一般是四点坐标(可能倾斜)。

  2. 文本识别(Text Recognition):对每个裁剪出的文本行小图,输出字符序列。


PP-OCRv5 还附带若干可选模块:文本行方向分类(判断单行 0°/180°)、文档方向分类(整图 0/90/180/270°)、文档矫正(unwarp,展平弯曲文档)。前两个必需,后三个按场景启用——扫描件、拍照文档才需要。

为什么必须先检测再识别?识别网络的输入被固定成"单行、高度归一化"的小图(常见3×48×W),一次只吐一行字。整页文档直接塞进去没有意义,"把大图切成行"必须由检测网络完成。

输入图像 ├─► 检测模型 ─► 概率图 ─► 二值化 ─► 轮廓 ─► 文本框(四点) │ │ │ 按框裁剪 + 透视变换 │ ▼ └─────────────────────────────► 识别模型 ─► CTC 解码 ─► 字符串

二、检测原理:DB(Differentiable Binarization)

PP-OCR 的检测网络基于DB(Differentiable Binarization,可微二值化),它解决了传统分割后处理"二值化不可导、阈值难以学习"的问题。模型输出一张单通道概率图(probability map),尺寸通常是输入图的 1/4(主干下采样)。训练时 DB 让网络同时学习"文本区域"和"文本边界",推理时只关心概率图——好处是相邻但独立的文本行能被清晰分开,因为边界处概率被压低了。

后处理固定四步:


  1. 二值化:bitmap = (prob > thresh) ? 255 : 0,thresh通常取 0.3 附近(官方配置里就叫thresh)。

  2. 轮廓提取:cv::findContours在 bitmap 上找连通域。

  3. 框筛选:用boxThresh(常见 0.6)过滤"平均置信度太低"的轮廓——把轮廓填充成 mask,统计原概率图在 mask 内的均值,低于阈值就丢弃;再用最小面积滤掉噪点。

  4. 框扩张:用 unclip(本质是 Vatti clipping 的多边形外扩)按unclipRatio(常见 1.5~2.0)放大。这一步极关键:概率图上的文本区域比真实文字"瘦一圈",不扩张会裁掉边缘笔画,识别准确率明显下降。


扩张后用cv::minAreaRect拿到四点坐标,就是送往识别阶段的文本框。

三、识别原理:SVTR 系列网络 + CTC 解码

识别网络输入NCHW = 1×3×48×W(高度固定 48,宽度按比例缩放,常见上限 320,超长文本行需分段),输出1×T×C:T是时间步,C = 字典大小 + 1(多出的一个是 CTC blank)。

CTC(Connectionist Temporal Classification)解决"输入长度 T 与输出字符数不等"的对齐问题,推理阶段用最简单的greedy decode:

对每个时间步 t: idx = argmax(score[t]) if idx == 0: 跳过 # 0 是 CTC blank elif idx == 上一个 idx: 跳过 # 合并连续重复 else: 保留 idx

保留的索引查字典即得文本。两个细节:索引 0 是 blank,不是字典字符;字典索引需要偏移 1(类别idx对应dict[idx - 1])。具体偏移方式取决于字典文件是否含 blank 占位,以官方字典文件与解码脚本为准。

工程上通常逐张识别(batch=1),这也正是性能瓶颈——检测一次,识别 N 次。

四、模型准备

从 PaddleOCR 官方仓库或模型库下载 PP-OCRv5 的inference 模型(不是训练模型),通常包含inference.json(新版 Paddle,PIR 格式)或inference.pdmodel(旧版),外加inference.pdiparams权重。另外需要字典文件(如ppocrv5_dict.txt),CTC 解码靠它把索引变成字符。下载地址与文件命名以官方文档为准。

用paddle2onnx转换(新旧格式命令略有差别):

pip install paddle2onnx onnx onnxruntime # 旧格式:.pdmodel + .pdiparams paddle2onnx --model_dir ./models/PP-OCRv5_mobile_det \ --model_filename inference.pdmodel --params_filename inference.pdiparams \ --save_file ./models/det.onnx --opset_version 11 --enable_onnx_checker True # 新格式(Paddle 3.x):inference.json + inference.pdiparams paddle2onnx --model_dir ./models/PP-OCRv5_mobile_det \ --model_filename inference.json --params_filename inference.pdiparams \ --save_file ./models/det.onnx --opset_version 11 --enable_onnx_checker True

转换后务必验证输入输出形状:

python -c "import onnx; m=onnx.load('./models/det.onnx'); \ print([(i.name,[d.dim_value or d.dim_param for d in i.type.tensor_type.shape.dim]) for i in m.graph.input]); \ print([(o.name,[d.dim_value or d.dim_param for d in o.type.tensor_type.shape.dim]) for o in m.graph.output])"

若输入是动态形状(dim_param),说明宽高可变,能省掉 padding;但某些 CPU EP 在动态形状上反而更慢,可视情况固化。

五、预处理

预处理最容易写错、也最影响精度。核心原则:训练时怎么处理,推理时就一模一样。

检测侧:BGR →RGB;按比例缩放并让长短边都是32 的整数倍(主干下采样 32 倍);归一化x = (x/255 - mean) / std,常用mean = [0.485, 0.456, 0.406]、std = [0.229, 0.224, 0.225];转 NCHW 的float32。归一化参数以官方配置为准。

// 检测预处理:等比例 resize 到 32 的倍数 + 归一化 + HWC->CHW static void preprocessDet(const cv::Mat& bgr, std::vector<float>& out, std::vector<int64_t>& shape, float& scale, int sideLimit = 960) { cv::Mat rgb; cv::cvtColor(bgr, rgb, cv::COLOR_BGR2RGB); // 1) BGR -> RGB const int h = rgb.rows, w = rgb.cols; const int maxSide = std::max(h, w); const double ratio = (maxSide > sideLimit) ? static_cast<double>(sideLimit) / maxSide : 1.0; // 2) 贴到 32 的倍数 const int rh = std::max(32, (int)std::round(h * ratio / 32.0) * 32); const int rw = std::max(32, (int)std::round(w * ratio / 32.0) * 32); cv::Mat resized; cv::resize(rgb, resized, cv::Size(rw, rh), 0, 0, cv::INTER_LINEAR); scale = static_cast<float>(rw) / w; // 后处理要按它映射回原图 // 3) 归一化 + 4) HWC -> CHW const float mean[3] = {0.485f, 0.456f, 0.406f}; const float stdv[3] = {0.229f, 0.224f, 0.225f}; out.assign(static_cast<size_t>(3) * rh * rw, 0.0f); for (int y = 0; y < rh; ++y) { const cv::Vec3b* row = resized.ptr<cv::Vec3b>(y); for (int x = 0; x < rw; ++x) for (int c = 0; c < 3; ++c) out[static_cast<size_t>(c) * rh * rw + y * rw + x] = (row[x][c] / 255.0f - mean[c]) / stdv[c]; } shape = {1, 3, rh, rw}; }

识别侧:把透视矫正后的文本行图 resize 到高 48、宽按比例;宽度 pad 到固定值(如 320),pad 部分填 0(归一化后的 0 对应灰色,与 Paddle 一致);归一化常用mean = 0.5, std = 0.5,即(x/255 - 0.5) / 0.5。同样以官方配置为准。代码结构与上面完全一致,只是把目标尺寸换成cv::Size(resizedW, 48)、归一化换成上式,并先在imgH × targetW的黑色画布上copyTo到左上角。

六、推理:ONNX Runtime C++ API

#include <onnxruntime_cxx_api.h> class OrtEngine { public: OrtEngine(const std::string& modelPath, int threads = 4) : env_(ORT_LOGGING_LEVEL_WARNING, "ppocr") { Ort::SessionOptions opts; opts.SetIntraOpNumThreads(threads); opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); opts.EnableCpuMemArena(); #ifdef _WIN32 // Windows 下 Ort::Session 只收宽字符路径 std::wstring wpath(modelPath.begin(), modelPath.end()); session_ = std::make_unique<Ort::Session>(env_, wpath.c_str(), opts); #else session_ = std::make_unique<Ort::Session>(env_, modelPath.c_str(), opts); #endif Ort::AllocatorWithDefaultOptions alloc; for (size_t i = 0; i < session_->GetInputCount(); ++i) inputNames_.emplace_back(session_->GetInputNameAllocated(i, alloc).get()); for (size_t i = 0; i < session_->GetOutputCount(); ++i) outputNames_.emplace_back(session_->GetOutputNameAllocated(i, alloc).get()); } std::vector<Ort::Value> Run(const std::vector<float>& data, const std::vector<int64_t>& shape) { auto memInfo = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input = Ort::Value::CreateTensor<float>( memInfo, const_cast<float*>(data.data()), data.size(), shape.data(), shape.size()); std::vector<const char*> inNames, outNames; for (auto& s : inputNames_) inNames.push_back(s.c_str()); for (auto& s : outputNames_) outNames.push_back(s.c_str()); return session_->Run(Ort::RunOptions{nullptr}, inNames.data(), &input, 1, outNames.data(), outNames.size()); } private: Ort::Env env_; std::unique_ptr<Ort::Session> session_; std::vector<std::string> inputNames_, outputNames_; };

读输出的正确姿势:Run()返回的Ort::Value里是指向 ORT 内部缓冲的视图,不是拷贝。要么当场处理完,要么立刻拷走;不要把裸指针存到下一轮Run()之后再用。同时必须用GetTensorTypeAndShapeInfo().GetShape()拿动态形状的实际值,不能假定输出尺寸等于你算出来的尺寸。

七、后处理

7.1 DB 后处理:轮廓 → 框

struct TextBox { std::vector<cv::Point2f> pts; float score; }; // prob: 1*1*H*W 概率图;scale 为预处理缩放比 static std::vector<TextBox> postprocessDet(const float* prob, int H, int W, float scale, float thresh = 0.3f, float boxThresh = 0.6f, float unclipRatio = 1.6f) { cv::Mat probMat(H, W, CV_32FC1, const_cast<float*>(prob)), bitmap; cv::threshold(probMat, bitmap, thresh, 255.0, cv::THRESH_BINARY); bitmap.convertTo(bitmap, CV_8UC1); std::vector<std::vector<cv::Point>> contours; cv::findContours(bitmap, contours, cv::RETR_LIST, cv::CHAIN_APPROX_SIMPLE); std::vector<TextBox> boxes; for (const auto& contour : contours) { if (contour.size() < 4) continue; // 用轮廓 mask 统计概率均值,过滤低置信框 cv::Mat mask = cv::Mat::zeros(probMat.size(), CV_8UC1); cv::drawContours(mask, std::vector<std::vector<cv::Point>>{contour}, -1, cv::Scalar(255), cv::FILLED); const float score = cv::mean(probMat, mask)[0]; if (score < boxThresh) continue; cv::RotatedRect rr = cv::minAreaRect(contour); const float area = rr.size.area(); const float peri = cv::arcLength(contour, true); if (peri < 1e-6f || area < 1.0f) continue; // DB 的 offset 近似:distance = area * ratio / perimeter const float dist = area * unclipRatio / peri; rr.size.width += 2.0f * dist; rr.size.height += 2.0f * dist; const float bw = rr.size.width, bh = rr.size.height; if (std::min(bw, bh) < 3.0f) continue; // 太小 if (std::max(bw, bh) / std::max(1.0f, std::min(bw, bh)) > 30.0f) continue; // 过于细长 cv::Point2f pts[4]; rr.points(pts); TextBox tb; tb.score = score; for (int i = 0; i < 4; ++i) // 映射回原图 tb.pts.emplace_back(pts[i].x / scale, pts[i].y / scale); boxes.push_back(std::move(tb)); } return boxes; }

拿到四点框后不要用cv::boundingRect直接裁剪——那会把倾斜文本行裁歪。正确做法是透视变换(perspective transform)拉正:

// 排序为 TL, TR, BR, BL static void orderPoints(std::vector<cv::Point2f>& p) { std::sort(p.begin(), p.end(), [](const cv::Point2f& a, const cv::Point2f& b) { return a.x < b.x; }); if (p[0].y > p[1].y) std::swap(p[0], p[1]); // 左侧上下 if (p[2].y > p[3].y) std::swap(p[2], p[3]); // 右侧上下 std::swap(p[1], p[2]); // 调整为 TL,TR,BR,BL } static cv::Mat cropByPerspective(const cv::Mat& src, std::vector<cv::Point2f> pts) { orderPoints(pts); const int outW = std::max(1, (int)std::round(std::max(cv::norm(pts[1] - pts[0]), cv::norm(pts[2] - pts[3])))); const int outH = std::max(8, (int)std::round(std::max(cv::norm(pts[3] - pts[0]), cv::norm(pts[2] - pts[1])))); std::vector<cv::Point2f> dst = { {0.f, 0.f}, {(float)(outW - 1), 0.f}, {(float)(outW - 1), (float)(outH - 1)}, {0.f, (float)(outH - 1)}}; cv::Mat M = cv::getPerspectiveTransform(pts, dst), warped; cv::warpPerspective(src, warped, M, cv::Size(outW, outH), cv::INTER_CUBIC, cv::BORDER_REPLICATE); return warped; }

7.2 CTC 解码与主干

// logits: 1*T*C 裸数据;dict 为不含 blank 的字符表 static std::string ctcDecode(const float* logits, int T, int C, const std::vector<std::string>& dict) { std::string result; int lastIdx = -1; for (int t = 0; t < T; ++t) { const float* row = logits + static_cast<size_t>(t) * C; int best = 0; for (int c = 1; c < C; ++c) if (row[c] > row[best]) best = c; if (best == 0 || best == lastIdx) { lastIdx = best; continue; } // blank/重复 lastIdx = best; const int di = best - 1; // 字典索引偏移 1 if (di >= 0 && di < (int)dict.size()) result += dict[di]; } return result; } int main(int argc, char** argv) { if (argc < 3) { std::cerr << "用法: " << argv[0] << " <image> <dict.txt>\n"; return 1; } OrtEngine det("./models/det.onnx", 4), rec("./models/rec.onnx", 4); auto dict = loadDict(argv[2]); // 一行一个字符 cv::Mat img = cv::imread(argv[1]); if (img.empty()) { std::cerr << "读图失败\n"; return 1; } // ---- 检测 ---- std::vector<float> detIn; std::vector<int64_t> detShape; float scale = 1.0f; preprocessDet(img, detIn, detShape, scale); auto detOut = det.Run(detIn, detShape); auto oShape = detOut[0].GetTensorTypeAndShapeInfo().GetShape(); // {1,1,H,W} // 后处理必须在 detOut 还活着时做完(或先把数据拷出来) auto boxes = postprocessDet(detOut[0].GetTensorData<float>(), (int)oShape[2], (int)oShape[3], scale); // ---- 逐个识别 ---- for (const auto& box : boxes) { cv::Mat line = cropByPerspective(img, box.pts); if (line.empty()) continue; std::vector<float> recIn; std::vector<int64_t> recShape; preprocessRec(line, recIn, recShape); // 高 48、pad 到 320 auto recOut = rec.Run(recIn, recShape); auto rShape = recOut[0].GetTensorTypeAndShapeInfo().GetShape(); // {1,T,C} std::string text = ctcDecode(recOut[0].GetTensorData<float>(), (int)rShape[1], (int)rShape[2], dict); if (!text.empty()) std::cout << "[" << box.score << "] " << text << "\n"; } return 0; }
cmake_minimum_required(VERSION 3.16) project(ppocrv5_cpp LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenCV REQUIRED COMPONENTS core imgproc imgcodecs) # 若无 onnxruntime 的 CMake 配置包,改为手动指定: # target_include_directories(... PRIVATE ${ORT_ROOT}/include) # target_link_directories(... PRIVATE ${ORT_ROOT}/lib) # target_link_libraries(... PRIVATE onnxruntime) find_package(onnxruntime REQUIRED) add_executable(ppocrv5_cpp main.cpp) target_link_libraries(ppocrv5_cpp PRIVATE ${OpenCV_LIBS} onnxruntime::onnxruntime)

常见坑点

坑 1:字典索引偏移算错,输出整体偏移一位

CTC 类别数C = 字典大小 + 1,多出的那个就是 blank。

❌result += dict[best];——best == 0时越界或取到 blank。

✅if (best == 0) continue; result += dict[best - 1];

但要注意:有些字典文件本身第一行就是blank占位,此时不该再偏移。判断方法是打印字典第一行看它是不是空格或blank;最可靠的还是对照官方解码脚本。另外若开了use_space_char,空格通常是字典最后一个字符,别把它 trim 掉。

坑 2:RGB / BGR 搞反

cv::imread默认给 BGR,而 PaddleOCR 训练用 RGB。用 BGR 推理不会崩溃也不报错,只是精度悄悄掉几个点,极难排查。

❌cv::Mat rgb = cv::imread(path);—— 变量名叫 rgb,其实还是 BGR。

✅cv::Mat bgr = cv::imread(path); cv::Mat rgb; cv::cvtColor(bgr, rgb, cv::COLOR_BGR2RGB);

坑 3:NCHW 与 HWC 写反

模型输入是1×3×H×W,内存必须先通道后空间(CHW)。把cv::Mat的连续内存直接memcpy进去得到的是 HWC,输出会变成随机噪声。

❌std::memcpy(data, resized.data, resized.total() * 3 * sizeof(float));

✅ 按c * H * W + y * W + x三重循环写入。也可用cv::dnn::blobFromImage自动完成 HWC→CHW,但要注意它只做减法不做除法,而 Paddle 的归一化是(x/255 - mean) / std,除以std那一步必须自己补。手写循环虽"笨"却最不容易错。

坑 4:检测框没映射回原图坐标

预处理做过 resize,后处理拿到的框在缩放后图像坐标系里。

❌img(cv::Rect(box.x, box.y, box.w, box.h))—— 坐标没除 scale。

✅ 在postprocessDet里就完成映射:tb.pts.emplace_back(pts[i].x / scale, pts[i].y / scale);

坑 5:unclipRatio调不好,框重叠或笔画被截断

太小则文字边缘笔画被切掉,识别出"缺胳膊少腿"的结果;太大则相邻文本行的框重叠,裁剪出串行图。小字号密集文本可调小(1.3~1.5),大字号笔画外扩明显可调大(1.8~2.0)。这是必须按业务数据调的超参,可以用官方 Python 版参数作起点,再拿自己的样本做小规模对比。

坑 6:Ort::Value生命周期,悬垂指针

Ort::Value是 RAII 包装,GetTensorData<float>()返回的是内部缓冲裸指针,随Ort::Value析构失效。

❌

const float* prob = nullptr; { auto outs = det.Run(...); prob = outs[0].GetTensorData<float>(); // outs 出作用域即销毁 } use(prob); // 悬垂指针,行为未定义

✅ 让Ort::Value活到使用结束,或立刻拷走:

std::vector<float> probCopy; { auto outs = det.Run(...); const float* p = outs[0].GetTensorData<float>(); probCopy.assign(p, p + outs[0].GetTensorTypeAndShapeInfo().GetElementCount()); } use(probCopy);

坑 7:把cv::Mat当成永远连续

cv::Mat只有isContinuous()为真时才能当一维数组遍历;ROI 裁剪、colRange之后往往不连续。

❌std::memcpy(dst, mat.data, mat.total() * mat.elemSize());

✅if (!mat.isContinuous()) mat = mat.clone();或用ptr<Vec3b>(y)逐行访问。

坑 8:中文路径读图失败

Windows 上cv::imread用窄字符路径,遇到中文可能返回空Mat。稳妥做法是自己读二进制再imdecode:

std::ifstream ifs(path, std::ios::binary); std::vector<char> buf((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>()); cv::Mat img = cv::imdecode(buf, cv::IMREAD_COLOR);

ONNX Runtime 的Ort::Session在 Windows 下同样只接受wchar_t*路径(前文代码已处理)。

总结

阶段关键动作最易出错处
模型准备下载 inference 模型 /paddle2onnx转换新旧格式文件名不同;没验证输入输出形状
检测预处理RGB、32 倍数缩放、ImageNet 归一化、CHW忘记录缩放比;漏除 std
检测后处理thresh → 轮廓 → boxThresh → unclip → 四点框unclipRatio 不当;没映射回原图
裁剪透视变换拉正用 boundingRect 裁倾斜文本行
识别预处理高 48、pad 到 320、mean=std=0.5pad 值不是 0;超长行无分段
识别后处理CTC greedy + 字典偏移blank 与字典索引偏移搞错

纯 C++ 部署 PP-OCRv5 的难点从来不在推理本身——ONNX Runtime 的 API 就那么几个。真正花时间的是三件事:预处理必须与训练严格一致(颜色、归一化、缩放策略)、后处理的几何变换必须正确(坐标映射、透视矫正、unclip)、CTC 解码的索引偏移必须与字典对齐。

再强调一次:本文涉及的模型结构参数、归一化数值、字典格式都属于会随版本变化的内容,落地前请以 PaddleOCR 官方仓库的 config、paddle2onnx与 ONNX Runtime 官方文档为准,并拿官方 Python 实现的输出逐层对齐——先保证同一张图下 Python 与 C++ 的检测框坐标误差在 1 像素内、识别文本完全一致,再去做性能优化。这一步做扎实了,就不会有"精度莫名其妙掉了 10 个点"的灵异事件。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询