ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从Keras到OpenCV C++:手写数字识别模型部署实战

从Keras到OpenCV C++:手写数字识别模型部署实战 简介深度学习模型训练完成后部署到生产环境往往比训练本身更具挑战。尤其在跨语言场景下模型需要转换为无依赖的静态推理格式才能在C等原生环境中高效运行。TensorFlow的冻结图pb格式正好满足这一需求它将权重和计算图固化为单一文件无需TensorFlow运行时即可被OpenCV DNN模块加载。OpenCV作为主流计算机视觉库其DNN模块为轻量级推理提供了便捷接口适合桌面程序、嵌入式视觉等场景。通过一个完整的手写数字识别项目从Keras训练、导出pb模型到OpenCV C代码中实现图像预处理、推理与结果解析重点剖析模型格式转换中的版本兼容、节点命名、数据对齐等工程实践问题为图像分类任务的跨平台部署提供了一套可复用的参考方案。 很多做深度学习的朋友都会遇到同一个“怪圈”模型在训练集上跑得飞快准确率也刷到99%但一旦要把它塞进一个真正的产品里——比如一个 C 编写的桌面程序或者一套嵌入式视觉系统——就开始无限卡壳。最近我正好用 TensorFlow 2.0 做了一个手写数字识别系统原本想快速收工没想到大部分时间都花在了“把模型导出成 pb 格式再让 OpenCV 的 DNN 模块在 C 里成功加载”这条路上。这篇文章就是那次实践的完整复盘从训练、导出 pb 模型到 OpenCV C 调用每一步都尽量说透尤其把那些文档里根本没写清的坑单独拎出来讲。我相信很多人跟我一样最初以为部署就是把model.save(mnist.h5)出来的文件复制过去就行。但 OpenCV 的 DNN 模块只认冻结的 pb 图GraphDef或者 TensorFlow 的 saved_model 目录并不直接支持 Keras 的 h5 格式。所以整个项目真正有价值的并不是那几行训练代码而是“如何稳妥地完成模型格式转换”和“如何在 C 里对齐预处理与推理”。下面我把整个过程按实际推进顺序拆开讲每个阶段都会附上我认为最实用的代码和最容易踩的坑。1. 项目拆解一个“可部署”的手写数字识别系统都在做什么1.1 训练模型只是起点部署才是关键手写数字识别本身是个经典的入门问题MNIST 数据集随便搭个两层卷积网就能轻松达到 99.2% 以上的准确率。在这个项目里真正的难点从来不是把模型训练到多少 acc而是“怎么让这个模型在一个没有 Python 环境、没有 TensorFlow 运行时的 C 工程里稳定地跑起来”。这就必须依赖一个跨语言的推理接口而 OpenCV 的 DNN 模块刚好是图像领域最常用的轻量级解决方案。我当时的诉求很明确把训练好的模型交给公司现有的一套 Qt/C 项目用不能为此再引入庞大的 TensorFlow 依赖。于是选择了 pb 模型 OpenCV DNN。这个组合的好处是OpenCV 本身就在项目里不需要额外安装庞大的 CUDA 运行时pb 文件体积小加载速度快适合单机离线推理对于 MNIST 这种小型卷积网络OpenCV DNN 的推理速度完全够用。1.2 为什么选择 TensorFlow 2.0 pb OpenCV DNN 这条链路很多人会疑问TensorFlow 2.0 主推的是 SavedModel 格式为什么非要去折腾 pb其实 OpenCV 的readNetFromTensorflow接口从设计上一直沿用的是 TensorFlow 1.x 的冻结图 pb 格式也就是把模型结构和权重全部固化到一个.pb文件中不依赖 checkpoint 和变量。虽然 OpenCV 4.x 开始也支持直接读 SavedModel 目录但对于 C 项目来说打包一个独立的 pb 文件明显更干净部署的时候也方便拷贝。所以我决定还是按“训练 → 导出冻结 pb → OpenCV 调用”这条路走。2. 环境准备TensorFlow 2.0 与 OpenCV C 开发环境2.1 TensorFlow 2.0 安装与版本坑我本机的环境是 Windows 10 Python 3.7使用 pip 安装 TensorFlow 2.0建议安装 2.0 或以上但不要超过 2.10因为 2.11 之后 Windows 上的 GPU 支持变得更麻烦而 CPU 版用起来差不多。一句命令搞定pip install tensorflow2.10.0如果你在 Linux 上跑建议先建立conda虚拟环境再把 tensorflow 装进去。这里有一个比较隐蔽的坑TensorFlow 2.0 的tf.compat.v1.keras.backend.get_session()在 eager 模式下不可用如果后面要冻结图必须在脚本最开头加上tf.compat.v1.disable_eager_execution()否则Session会直接报错。这个坑我后面专门讲。2.2 OpenCV C 开发环境配置Windows/Linux 双平台OpenCV 的部分我分别试过两个方案Windows直接用官方预编译的 OpenCV 4.5.x 版本解压后设置环境变量在 VS2019 的VC 目录里配置 include 和 lib 路径。Linux推荐用 apt 安装libopencv-dev或者从源码编译。源码编译需要确认打开了OPENCV_DNN_MODULE和OPENCV_DNN_TFLITE等选项默认是开启的但如果你用cmake -D BUILD_LIST自定义过模块得手动检查一下。一个必须注意的点OpenCV DNN 模块本身不需要opencv_contrib但如果你用的是老版本比如 3.4.x对 TensorFlow 2.0 导出的 pb 支持会比较差。建议直接用 4.x 及以上版本我实测 OpenCV 4.5.5 加载 TF2.0 导出的 pb 没有任何问题。2.3 工程目录规划与源码结构这个项目里的文件结构我梳理成了这样mnist_recognizer/ ├── train.py # 训练脚本输出 h5 和 SavedModel ├── export_pb.py # 把 h5 冻结成 pb ├── mnist_cnn.h5 # Keras 权重 ├── mnist_model.pb # 导出后的冻结图 ├── cpp_demo/ │ ├── CMakeLists.txt │ └── main.cpp # OpenCV C 推理程序 ├── image/ # 测试图片建议你在项目一开始就按这个结构建好目录避免后面文件混在一起。3. 训练一个易部署的手写数字识别模型Keras 实现3.1 模型结构设计给输入输出起好名字在训练阶段就要为导出做准备。最关键的一点是给模型的输入输出张量起一个固定的名字否则导出后 OpenCV 那边找不到节点名会很麻烦。我用一个很简朴的卷积网络没有加 BatchNormalization因为当时发现 OpenCV DNN 对 BN 节点的兼容在某些版本上会报Unknown layer type为了绕开这个坑才去掉了 BN。这个模型足够在 MNIST 上达到 99.2% 以上。import tensorflow as tf from tensorflow.keras import layers def create_model(): inputs tf.keras.Input(shape(28, 28, 1), nameinput) x layers.Conv2D(32, (3, 3), activationrelu)(inputs) x layers.MaxPooling2D((2, 2))(x) x layers.Conv2D(64, (3, 3), activationrelu)(x) x layers.MaxPooling2D((2, 2))(x) x layers.Flatten()(x) x layers.Dense(128, activationrelu)(x) x layers.Dropout(0.5)(x) outputs layers.Dense(10, activationsoftmax, nameoutput)(x) model tf.keras.Model(inputs, outputs) model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy]) return model注意input和output这两个名字是我故意指定的后面导出冻结图时输入输出节点名就会以input和output结尾这样在 OpenCV 里可以直接用字符串传进去。3.2 训练与保存同时保存 h5 和 SavedModel训练部分很简单用自带 MNIST 数据集mnist tf.keras.datasets.mnist (x_train, y_train), (x_test, y_test) mnist.load_data() x_train x_train.reshape(-1, 28, 28, 1).astype(float32) / 255.0 x_test x_test.reshape(-1, 28, 28, 1).astype(float32) / 255.0 model create_model() model.fit(x_train, y_train, batch_size128, epochs5, validation_split0.1) model.save(mnist_cnn.h5)这里我故意只训练 5 个 epoch因为 MNIST 太简单5 个 epoch 已经能稳定到 99% 左右没必要把时间浪费在训练上。如果你要更高的准确率可以把 epochs 加到 10并加入tf.keras.callbacks.ReduceLROnPlateau。3.3 验证模型用真实手写图片测试训练完先别急着导出先跑一下模型看看效果。这里我拿了一张自己用画图工具写的数字“7”作为测试代码如下import cv2 import numpy as np from tensorflow.keras.models import load_model img cv2.imread(image/test7.png, cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (28, 28)).astype(float32) / 255.0 img img.reshape(1, 28, 28, 1) model load_model(mnist_cnn.h5) pred model.predict(img) print(预测值:, np.argmax(pred))这一步能确保模型本身没问题后面出异常时就可以把问题缩小在“格式转换”或“OpenCV 调用”上。4. 核心环节将 Keras 模型导出为 OpenCV DNN 可加载的 pb 模型4.1 pb 与 SavedModel 的区别为什么 OpenCV 需要冻结图TensorFlow 里有好几种“模型”概念Keras 的.h5保存的是权重和网络结构但权重是动态变量需要 TensorFlow 运行时去初始化。SavedModel包含图结构、变量文件、签名通常用于 TensorFlow Serving但它是一个目录不是单文件。冻结图Frozen Graph / GraphDef把变量全部转换成常量整个网络变成了一个静态的、无状态的.pb图文件。OpenCV 的 DNN 模块需要的正是这种静态的GraphDef。因为它没有一个 TensorFlow 的 variable 存储管理器只有当一个图里所有变量都被固化为常量后它才能通过一张图字节流完成前向计算。所以这个“导出 pb”的核心动作就是“冻结”权重。4.2 方法一用 TensorFlow 2.0 的冻结图工具兼容 v1 接口tf.compat.v1.graph_util.convert_variables_to_constants是常见的冻结工具但它在 eager 模式下无法运行。所以我在export_pb.py脚本开头强制关闭 eager。import tensorflow.compat.v1 as tf tf.disable_eager_execution() from tensorflow.keras import models # 加载训练好的 h5 模型 model models.load_model(mnist_cnn.h5) # 这里需要确保模型已经加载并且 session 是全局的 sess tf.keras.backend.get_session() # 打印输入输出节点名方便后面 OpenCV 使用 print(input name:, model.input.name) print(output name:, model.output.name) # 冻结图把图中 Variable 变成常量 frozen_graph tf.graph_util.convert_variables_to_constants( sess, sess.graph.as_graph_def(), [model.output.op.name] # 输出节点的 op 名 ) # 保存为 pb 文件 with tf.gfile.GFile(mnist_model.pb, wb) as f: f.write(frozen_graph.SerializeToString()) print(pb saved)运行后屏幕打印的input name会是input:0output name会是output/Softmax:0。这里我们要在 OpenCV 中使用的节点名并不是带:0的字符串而是不带索引的节点名也就是input和output/Softmax。不过实际上convert_variables_to_constants接收的是 op 名model.output.op.name返回的就是output/Softmax。4.3 方法二通过 SavedModel 转换更贴近生产环境如果你不想关 eager也可以走saved_model路线。先把模型保存成 SavedModeltf.saved_model.save(model, saved_model)然后用saved_model_cli或者 Python 载入tf.saved_model并导出实现。但注意OpenCV 不能直接加载整个saved_model目录你仍然需要把里面的具体函数冻结成单张 pb。这个方式在纯 TF2.0 的工程里更“正统”但步骤繁琐还要处理tf.function的 signature。我亲测下来还是方法一更快而且对于手写数字识别这种简单网络完全够用。4.4 验证导出的 pb 模型用 TensorFlow 脚本测试输出结果导出 pb 后先用 TensorFlow 验证一下这个 pb 还能不能推理避免等会儿在 C 里发现模型坏了再回来排查。这里我用tf.Graph载入 pb 并跑一次import tensorflow as tf import numpy as np with tf.Graph().as_default(): graph_def tf.GraphDef() with open(mnist_model.pb, rb) as f: graph_def.ParseFromString(f.read()) tf.import_graph_def(graph_def, name) x np.random.rand(1, 28, 28, 1).astype(float32) with tf.Session() as sess: input_tensor sess.graph.get_tensor_by_name(input:0) output_tensor sess.graph.get_tensor_by_name(output/Softmax:0) result sess.run(output_tensor, feed_dict{input_tensor: x}) print(result.shape)这一步通过说明 pb 文件没有损坏可以放心去 OpenCV 那边找茬。5. OpenCV C DNN 模块调用 pb 模型代码详解5.1 readNetFromTensorflow 加载模型写好 C 示例时我直接用一个最简的main.cpp来演示。先在项目里配置好 OpenCV然后#include opencv2/opencv.hpp #include opencv2/dnn.hpp #include iostream using namespace cv; using namespace cv::dnn; int main() { // 加载 pb 模型 Net net readNetFromTensorflow(mnist_model.pb); if (net.empty()) { std::cerr 加载模型失败 std::endl; return -1; } std::cout 模型加载成功 std::endl; return 0; }实测这里有两个容易错的地方一是路径写错尤其 Windows 下要使用双斜杠二是 OpenCV 版本过老readNetFromTensorflow可能加载不了 TF2.0 的图。建议先用上面的代码把模型加载流程跑通再进行推理。5.2 图像预处理从任意图像到 28x28 单通道OpenCV 推理时最关键的是确保输入图片的处理方式和训练时一致。训练时我们做的是“灰度图 resize 到 28x28除以 255”归一化。在 C 里也一样而且要注意blobFromImage的用法。// 读取图片并转为灰度图 Mat img imread(test.png, IMREAD_GRAYSCALE); if (img.empty()) { std::cerr 图片读取失败 std::endl; return -1; } // 调整大小保持 28x28 Mat resized; resize(img, resized, Size(28, 28), 0, 0, INTER_AREA); // 转成 float 并归一化 resized.convertTo(resized, CV_32F, 1.0 / 255.0); // 创建 blob尺寸为 (1, 1, 28, 28) Mat blob blobFromImage(resized, 1.0, Size(28, 28), Scalar(0), false, false, CV_32F);我特意没有用blobFromImage的归一化参数而是先把 Mat 转成CV_32F再缩放因为这样可以少踩一次 channel 数和顺序的坑。对于单通道图像blobFromImage生成的内存布局是[1, 1, 28, 28]和 TensorFlow 期望的[1, 28, 28, 1]在内存中是一致的通道数为 1所以维度顺序不影响所以这里可以直接喂给网络。5.3 执行推理并解析结果设置输入和推理只需要两行net.setInput(blob, input); Mat prob net.forward(output/Softmax);setInput的第一个参数是 blob第二个参数是输入层节点名。这里要注意如果你的模型里输入节点名是input就写input如果你在导出时打印的是input:0不需要带:0。net.forward同理需要的是输出节点的 op 名。随后把prob从矩阵里取出来找最大概率对应的下标// prob 是 1x10 的矩阵 double minVal, maxVal; Point minLoc, maxLoc; minMaxLoc(prob, minVal, maxVal, minLoc, maxLoc); int label maxLoc.x; std::cout 识别结果: label 置信度: maxVal std::endl;如果net.forward没有指定输出节点名它会默认跑到最后一层但这里我们明确指定了output/Softmax保证 OpenCV 取到的是我们想要的 softmax 结果。5.4 完整示例批量识别并可视化结果为了便于测试我写了一个简单的批量处理遍历一个文件夹下的所有图片逐一识别并用 OpenCV 在图片上绘制结果。#include opencv2/opencv.hpp #include opencv2/dnn.hpp #include iostream #include vector #include string using namespace cv; using namespace cv::dnn; int main(int argc, char** argv) { Net net readNetFromTensorflow(mnist_model.pb); if (net.empty()) { std::cerr 模型加载失败 std::endl; return -1; } std::vectorstd::string files {1.png, 2.png, 3.png}; for (auto file : files) { Mat img imread(file, IMREAD_GRAYSCALE); if (img.empty()) continue; Mat resized; resize(img, resized, Size(28, 28), 0, 0, INTER_AREA); resized.convertTo(resized, CV_32F, 1.0 / 255.0); Mat blob blobFromImage(resized, 1.0, Size(28, 28), Scalar(0), false, false, CV_32F); net.setInput(blob, input); Mat prob net.forward(output/Softmax); double minVal, maxVal; Point minLoc, maxLoc; minMaxLoc(prob, minVal, maxVal, minLoc, maxLoc); int label maxLoc.x; Mat display; resize(img, display, Size(280, 280)); // 放大便于查看 putText(display, std::to_string(label), Point(20, 40), FONT_HERSHEY_SIMPLEX, 1.0, Scalar(0, 255, 0), 2); imshow(result, display); waitKey(0); } return 0; }到这里一个完整的“训练 → 导出 pb → OpenCV C 调用”闭环就通了。6. 从踩坑到记住OpenCV 调用 pb 模型常见问题6.1 版本兼容TensorFlow 2.4 的模型在 OpenCV 4.2 中加载报错我一开始项目用的是 OpenCV 4.2加载 TF2.0 出来的 pb 时直接报错Unspecified error during read of text graph后来查资料发现原因主要是 OpenCV 4.2 的 TFOpMapper 不认识一些 TensorFlow 2.x 原生操作比如FusedBatchNormV3或者VariableV2冻结后应该没有变量但历史节点可能残留。解决办法是在导出时关闭 eager 后冻结并且尽量用标准的Conv2D、ReLU、MaxPool、MatMul这些基础操作。另外升级到 OpenCV 4.5.5 后问题基本消失。所以我的建议是直接使用 OpenCV 4.5 及以上版本省心很多。6.2 输入输出节点名不对导致 setInput 失败第一次写 C 时我直接照着别人的代码用了input和softmax作为节点名结果运行时报错找不到输入层。后来我才意识到每个模型导出的 pb 节点名可能不一样。排查方法是先打开 pb 文件看节点名或者在导出前打印出来。如果需要硬编码节点名可以在导出脚本里加一个断言输出节点名后在 C 中写死// 导出脚本中打印 // input name: input:0 // output name: output/Softmax:0所以这里我用的是input和output/Softmax而不是想当然的softmax。6.3 图像预处理不一致导致识别率低下模型在测试集上 99%但拿到真实手写图片上识别率暴跌。这个问题多数出在预处理差异上训练时用的是 28x28 像素真实图片可能很大必须缩放到 28x28。训练时是灰度图如果直接读彩色图并丢给网络OpenCV 会默认转成 3 通道导致输入 shape 不对。归一化没做对。很多开源代码用1.0 / 255.0也有人减均值除以方差。一定要和训练时的处理完全一致。另外用resize时插值方法也会影响INTER_AREA在缩小时更友好而INTER_NEAREST很容易产生锯齿影响识别。6.4 单通道与三通道的隐性问题如果你的 OpenCVblobFromImage传入的是灰度图生成的是[1,1,28,28]而 TensorFlow 模型期望的是[1,28,28,1]前面我说过因为 C1 所以没问题。但如果你的模型是训练时使用了 3 通道输入比如把 MNIST 灰度图复制成三通道那你在 C 里就要手动把灰度图cvtColor(img, img, COLOR_GRAY2BGR)再转 blob否则会直接报 shape 不匹配。7. 完整源码与模型资源说明并附加改进思路7.1 源码文件结构与使用说明项目打包完成后你拿到的压缩包里基本包含train.py训练脚本输出mnist_cnn.h5export_pb.py导出mnist_model.pbmnist_cnn.h5Keras 模型权重mnist_model.pb冻结后的 TensorFlow 图供 OpenCV 使用cpp_demo/main.cppOpenCV C 推理完整示例使用顺序是先跑train.py再跑export_pb.py最后用 C 编译并运行main.cpp。如果你是拿现成的.pb文件直接用 C 部分即可。7.2 模型精度改进加深网络 / 数据增强 / 真实噪声测试如果觉得 99% 还不够可以从这几个方向改进在网络中加入BatchNormalization并测试 OpenCV 4.5.5 是否接受新版本已经支持了做数据增强比如随机旋转 10 度、平移 ±2 像素、添加椒盐噪声用自己的手写图片做更充分的测试不要只用 MNIST 的测试集因为真实场景的书写风格可能完全不同。我后来还尝试过把模型换成更轻的 MobileNet 结构但发现 OpenCV DNN 对这种大模型的加载速度会慢很多。对于单纯的手写数字识别还是推荐保持小型 CNN。7.3 部署扩展从 OpenCV DNN 到 TensorRT / ONNX Runtime如果你后续要部署到带 NVIDIA 显卡的设备上可以考虑把 pb 转成 ONNX再用 TensorRT 做加速。OpenCV DNN 目前性能已经不错但和专用推理引擎相比还是差一些。不过对于数字识别这种任务延迟在毫秒级OpenCV DNN 完全够用。如果以后要扩展到实时视频流识别建议用 ONNX Runtime C API因为它对硬件加速的支持更好生态也更现代。最后再分享一个我后来才意识到的小细节导出的 pb 模型最好在每次修改模型或训练超参后都重新导出并同时在 C 端做一个“冒烟测试”——用一张固定图片验证输出值是否和 TensorFlow 完全一致。这个习惯能让你在项目后期少掉一大半的头发。本文还有配套的精品资源点击获取
返回列表