
簡介深度學習模型訓練完成后部署到生產環境往往比訓練本身更具挑戰。尤其在跨語言場景下模型需要轉換為無依賴的靜態推理格式才能在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 完全一致。這個習慣能讓你在項目后期少掉一大半的頭發。本文還有配套的精品資源點擊獲取