简介:基于ONNXRuntime部署Ultra-Fast-Lane-Detection-v2车道线检测的完整工程包,面向自动驾驶、智能交通方向的开发者,以及需要落地推理部署的深度学习工程师。压缩包共23个文件,以18张示例图、C++与Python源码各2个、1份说明文档为主,整体仅4.35MB;图片用于运行效果可视化,C++版本适合低延迟性能场景,Python版本便于快速调试,说明文档辅助环境配置与流程梳理。已有548人学习下载。资源内含ONNX模型、双语言推理代码与图像运行样例,可直接在CPU或GPU环境复现;对比两套实现,能系统掌握模型转换、会话创建、输入预处理、车道线后处理等关键环节,为在真实道路场景中集成车道线识别功能提供可复用工程参考。
1. ONNXRuntime 部署 UFLD-v2 车道线:这套源码包能干什么
车道线检测做到“CPU 实时”是有捷径的,Ultra-Fast-Lane-Detection-v2 就是这条路上绕不开的模型:它不搞逐像素分割,而是把车道线检测转成行方向分类,省掉一大半计算量。手头这套资源就是围绕它来的,包含 C++ 和 Python 两套 ONNXRuntime 部署源码、训练好的 onnx 模型、测试图和一份 README,解压后能直接跑出车道线叠加结果。适合正在做 ADAS 预警、L2 车道保持、智能车竞赛的开发者,也适合想搞懂“分类式车道线后处理”的人。与其自己从 PyTorch 导模型再裸写推理,不如直接在这个工程上改输入输出。
2. UFLD-v2 模型结构与 ONNX 部署原理:先弄懂 hybrid anchor 再跑推理
2.1 Hybrid Anchor 是什么:为什么 UFLD-v2 不用语义分割
第一次跑 UFLD 系列的部署代码,很多人会困惑:为什么模型输出不是一张分割图,而是一个四维张量。这是因为 UFLD-v2 走的是“行分类 + anchor 分类”路线。每个预定义的行位置上,车道线只会落在一个水平位置或者干脆不存在,所以模型要预测的就是“这一行上哪一列是车道线”,本质是一个分类任务。
UFLD-v2 在 v1 基础上引入了 hybrid anchor,也就是局部 anchor 和全局 anchor 配合。局部 anchor 密集覆盖车身近处,弯道和坡度变化大的地方能表达得更细;全局 anchor 用来兜住远处接近消失点的大范围区域,避免只看近处导致远处车道线稀疏。这个设计直接影响了后面部署时的输出张量形状,所以后处理代码里那些 reshape 和 argmax 不是玄学,每一维都有实际含义。
对做部署的人来说,知道这条原理有个实际好处:当输出维度和你手里的模型不一致时,你能判断是 anchor 数量设置变了,而不是代码抄错。UFLD-v2 的输出一般不是[1, 4, 1001, 201]就是[1, 4, 201, 1001],差的就是维度顺序,后面讲避坑时会专门提这一点。
2.2 为什么选 ONNXRuntime:一份模型,三端复用
这个资源包用 ONNXRuntime 而不直接调 PyTorch,是因为 ONNXRuntime 对“部署”这件事的边界定义更清楚:模型是 PyTorch 导出的 onnx,训练框架和推理框架解耦,C++ 和 Python 共用同一个模型文件,不需要在两边维护两套权重。
ONNXRuntime 的核心优化都在 SessionOptions 和 ExecutionProvider 这两个概念里。SessionOptions 里的图优化级别能自动做算子融合,比如把 Conv + BatchNorm + ReLU 合并成一个节点;intra_op_num_threads 控制单算子内部线程数,对 CPU 推理影响很大。ExecutionProvider 则决定了模型跑在 CPU 还是 GPU,常见配置是CPUExecutionProvider和CUDAExecutionProvider,代码里可以在 providers 参数里同时传多个,运行时按顺序选。
Python 部署用的是onnxruntime.InferenceSession,C++ 部署用的是Ort::Session,两个 API 的加载逻辑完全对应,只是内存管理方式不同。这正是这套源码包的价值:先读 Python 版本把流程跑通,再对照 C++ 版本解决性能和内存问题,两套代码放一起看,部署的通用套路基本就摸清了。
2.3 从 PyTorch 到 ONNX:固定输入尺寸与输入输出名
资源包里的模型已经是 onnx 格式,正常情况下你不需要重新导出,但万一你想换 backbone 或者重训模型,导出这一步还是得会。常见做法是用torch.onnx.export,注意输入尺寸要固定,因为 UFLD-v2 的 anchor 数量是跟着输入分辨率走的,动态尺寸导出会让后处理代码没法写死形状。
import torch from model import Model # 以实际训练工程为准 model = Model(backbone="resnet18", num_lanes=4) model.load_state_dict(torch.load("ufldv2.pth", map_location="cpu")) model.eval() dummy_input = torch.randn(1, 3, 288, 800) torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], opset_version=17, dynamic_axes=None ) print("export done")这里dynamic_axes=None是刻意的,推理时输入必须是[1, 3, 288, 800],这样可以避开动态 shape 带来的一切麻烦。opset_version 建议用 17 左右,太老的 opset 有些算子不支持图优化,太新的又可能依赖新版本 onnxruntime。导出完先跑一次onnx.checker.check_model,再用onnxruntime做一次推理对比,确认输出数值和 PyTorch 基本一致再部署。
这套资源包自带的模型,输入输出规格一般是这样的(实际以文件打印为准):
| 名称 | Shape | 说明 |
|---|---|---|
| input | [1, 3, 288, 800] | RGB 图,归一化到 [-1, 1] |
| output | [1, 4, 1001, 201] | 4 个通道的 anchor logits |
| 预处理 | resize + 归一化 | (x / 255 - 0.5) / 0.5 |
拿到模型后第一件事是打印session.get_inputs()和session.get_outputs(),确认输入输出名是input/output。很多网上下载的模型导出时名字不一样,后面 C++ 代码里直接用字符串找 tensor 就会找不到,这一点在避坑章节还会展开。
3. Python 端部署主流程:InferenceSession、预处理与后处理全套代码
3.1 环境准备与文件构成:先看清资源包再动手
解压资源包后,目录结构大致是这样的:main.py、main.cpp、images/、onnxruntime/、README.md,模型文件也在包内。images目录放的是测试图片,onnxruntime目录里是动态库,README.md里会写明模型文件名和依赖版本,先读它再跑代码,能省掉一半排错时间。
Python 端环境只需要装三个库,建议直接用 pip 安装,版本差距不会太大:
pip install numpy opencv-python onnxruntime装完可以顺手验证一下 onnxruntime 是否正常工作。CPU 版本默认就有,GPU 版本需要额外装 CUDA 相关依赖,但先不要急着上 GPU,UFLD-v2 在 CPU 上单帧延迟已经能到几十毫秒量级,先把 CPU 流程跑通再考虑加速。我一般会用onnxruntime.get_available_providers()看一眼当前支持哪些 provider,确认CPUExecutionProvider在列表里。如果是在 Windows 上,注意 Python 最好是 3.8 到 3.11 之间,太新的版本有些预编译包可能没有对应 wheel。
3.2 Python 推理主流程:预处理、InferenceSession、Run
预处理是部署里最容易错的一环,核心要求是“和训练时保持一致”。UFLD-v2 训练用的图像是 RGB 顺序,尺寸是 800×288,先做(x / 255 - 0.5) / 0.5归一化,然后从 HWC 转成 CHW。OpenCV 读出来是 BGR,所以第一步必须转颜色空间。
import cv2 import numpy as np import onnxruntime as ort def preprocess(image_path, width=800, height=288): img = cv2.imread(image_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (width, height), interpolation=cv2.INTER_LINEAR) img = img.astype(np.float32) / 255.0 img = (img - 0.5) / 0.5 img = img.transpose(2, 0, 1)[np.newaxis, ...] return np.ascontiguousarray(img, dtype=np.float32) sess_options = ort.SessionOptions() sess_options.intra_op_num_threads = 4 sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED session = ort.InferenceSession( "model.onnx", sess_options, providers=["CPUExecutionProvider"] ) input_name = session.get_inputs()[0].name output_name = session.get_outputs()[0].name print("input:", input_name, session.get_inputs()[0].shape) print("output:", output_name, session.get_outputs()[0].shape) x = preprocess("images/test.jpg") outs = session.run([output_name], {input_name: x}) print("output shape:", outs[0].shape)这段代码里intra_op_num_threads设为 4 是个折中,线程数并不是越多越快,小模型线程开多了反而浪费在调度上。ORT_ENABLE_EXTENDED是比基本优化更激进的图优化级别,把能融合的算子尽量融合。get_inputs()[0].name这种方式比直接写死字符串更稳,因为模型输入名可能不是以为的input,只有打印出来才确定。后面几行代码就是在验证模型是否能正常加载、输入输出 shape 是否符合预期,这一步是后面所有调试的地基。
3.3 后处理与可视化:把输出转成车道关键点
模型输出的四维张量不是直接能画的车道线,要解码成一组关键点。这里的解码逻辑和 2.1 讲的 hybrid anchor 对应:输出的某个通道代表局部 anchor 的分类 logits,对它做 softmax 后逐行取最大类别,就能得到“这一行上车道线在哪一列”。
下面这份是简化版后处理,目的是让你先看到图上有线画出来,资源和官方完全对齐的解码以main.py里的post_process为准。
import numpy as np from scipy.special import softmax def decode_lanes(output, img_size=(800, 288), thresh=0.4): # output: [1, 4, 1001, 201],先压掉 batch 维 pred = output[0] local_logits = pred[0] # [1001, 201] row_prob = softmax(local_logits, axis=-1) col_idx = np.argmax(row_prob, axis=-1) # 每行最可能的列位置 row_conf = np.max(row_prob, axis=-1) points = [] for r in range(row_conf.shape[0]): if row_conf[r] < thresh: continue x = int(col_idx[r] / 201.0 * img_size[0]) y = int(r / 1000.0 * img_size[1]) points.append((x, y)) return points pts = decode_lanes(outs[0]) for p in pts: cv2.circle(img_show, p, 2, (0, 0, 255), -1)这段解码里axis=-1表示沿着最后一维做 softmax,也就是每个行位置上对 201 个列候选做分类。thresh用来过滤置信度太低的行位置,这部分在近处通常很稳定,远处接近消失点的区域置信度低,过滤掉反而干净。坐标映射要注意col_idx / 201 * 800才是原图横坐标,因为模型输出里的 201 对应的是列方向的网格数,不是像素坐标。
如果你把这张图画出来发现点全堆在图像一侧,大概率是维度顺序反了,也就是模型输出其实是[1, 4, 201, 1001],这时把 softmax 的 axis 改成 0,坐标映射反过来算就行。这个判断方法比反复猜代码更快,也是排查后处理问题的通用思路。
4. C++ 端部署主流程:Ort::Session 的内存管理与 OpenCV 交互
4.1 C++ 环境:onnxruntime 动态库与 OpenCV 版本选择
C++ 版本比 Python 多出来的工作量,集中在“动态库怎么链”和“内存怎么管”这两件事上。资源包里onnxruntime/目录放的是 onnxruntime 的动态库和头文件,如果你用的是 Windows,需要确认里面有onnxruntime.dll和onnxruntime_cxx_api.h;如果是 Linux,则是libonnxruntime.so。
OpenCV 版本建议用 4.x,4.5 以上都行,主要用到的是cv::dnn::blobFromImage和基础的图像读写。编译工具链上,Windows 可以用 Visual Studio 2022,也可以用 VS Code 配 CMake;Linux 上直接用 gcc 加 CMake。这里的关键是让 CMake 同时找到 OpenCV 和 onnxruntime 的头文件目录与库目录,常见做法是在 CMakeLists 里手动指定路径。
cmake_minimum_required(VERSION 3.16) project(ufldv2_onnx) find_package(OpenCV REQUIRED) set(ONNXRUNTIME_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/onnxruntime/include") set(ONNXRUNTIME_LIB_DIR "${CMAKE_SOURCE_DIR}/onnxruntime/lib") include_directories(${ONNXRUNTIME_INCLUDE_DIR} ${OpenCV_INCLUDE_DIRS}) link_directories(${ONNXRUNTIME_LIB_DIR}) add_executable(main main.cpp) target_link_libraries(main ${OpenCV_LIBS} onnxruntime)link_directories在这里是必需的,因为 onnxruntime 不是通过 find_package 找到的,只能手动指定库目录。Windows 上编译成功后,运行前记得把onnxruntime.dll复制到可执行文件同目录,或者把库目录加进系统 PATH,否则会报找不到动态库。还有一点容易踩:如果编译是 x64,但系统装的是 x86 的 Visual C++ Redistributable,运行时会报 0xc000007b 这类错误,后面对应专门讲。
4.2 Ort::Session 主流程:输入输出的内存管理
C++ 的Ort::Session使用方式跟 Python 的InferenceSession很像,差别在于所有 tensor 都要自己分配内存并用Ort::Value包装。一个很容易翻车的点是GetTensorMutableData拿到的是裸指针,OpenCV 的 Mat 数据指针如果生命周期结束,这块内存就失效了,所以要么把数据拷贝出来,要么保证 Mat 在推理期间一直存活。
#include <onnxruntime_cxx_api.h> #include <opencv2/opencv.hpp> #include <vector> int main() { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "ufldv2-lane"); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel(GRAPH_OPTIMIZATION_LEVEL_ENABLE_EXTENDED); const wchar_t* model_path = L"model.onnx"; Ort::Session session(env, model_path, session_options); // 打印模型输入输出信息 Ort::AllocatedStringPtr input_name = session.GetInputNameAllocated(0, Ort::AllocatorWithDefaultOptions()); Ort::AllocatedStringPtr output_name = session.GetOutputNameAllocated(0, Ort::AllocatorWithDefaultOptions()); printf("input: %s\n", input_name.get()); printf("output: %s\n", output_name.get()); std::vector<int64_t> input_shape{1, 3, 288, 800}; size_t input_size = 1 * 3 * 288 * 800; std::vector<float> input_data(input_size); // 这里把预处理后的数据填入 input_data Ort::MemoryInfo memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>(memory_info, input_data.data(), input_size, input_shape.data(), input_shape.size()); std::vector<const char*> input_names{input_name.get()}; std::vector<const char*> output_names{output_name.get()}; auto output_tensors = session.Run(Ort::RunOptions{nullptr}, input_names.data(), &input_tensor, 1, output_names.data(), output_names.size()); float* output_data = output_tensors[0].GetTensorMutableData<float>(); // output 形状从 output_tensors[0].GetTensorTypeAndShapeInfo() 读取 return 0; }这段代码里CreateTensor用的是input_data.data(),所以input_data不能在session.Run之前被销毁,这一点是 C++ 部署最常见的崩溃来源之一。Ort::AllocatedStringPtr是 onnxruntime 1.13 之后引入的智能指针类型,老的教程里直接返回char*的写法在新版本已经废弃。session.Run返回的是一个std::vector<Ort::Value>,这里只取第一个输出,如果你的模型有多个输出,记得用下标访问后再转成float*。
4.3 blobFromImage 预处理与坐标换算:和 Python 保持一致
C++ 端的预处理推荐绕开cv::dnn::blobFromImage,因为它的默认参数很容易让人算错归一化。blobFromImage内部做的是(src * scale - mean),没有除以标准差这一步,所以如果你直接写scale=1.0/255, mean=0.5,得出的结果是x/255 - 0.5,少了一个除以 0.5,模型输入分布就对不上,检测结果会明显变差。
更好的做法是手动完成归一化和 HWC 到 CHW 的转换,每一步都和 Python 版本对齐:
cv::Mat img = cv::imread("images/test.jpg"); cv::cvtColor(img, img, cv::COLOR_BGR2RGB); cv::resize(img, img, cv::Size(800, 288), 0, 0, cv::INTER_LINEAR); std::vector<cv::Mat> channels(3); cv::split(img, channels); for (int i = 0; i < 3; i++) { channels[i].convertTo(channels[i], CV_32FC1, 2.0 / 255.0, -1.0); } cv::merge(channels, img); // 现在是 HWC 的 float 图 float* input_data = new float[3 * 288 * 800]; int idx = 0; for (int c = 0; c < 3; c++) { for (int h = 0; h < 288; h++) { for (int w = 0; w < 800; w++) { input_data[idx++] = img.at<cv::Vec3f>(h, w)[c]; } } }这里convertTo的scale=2.0/255.0和shift=-1.0组合起来,等价于(x / 255.0 - 0.5) / 0.5,因为2*x/255 - 1 = 2 * (x/255 - 0.5)。手动三重循环转 CHW 虽然慢,但保证和 Python 的transpose(2,0,1)完全一致。如果你追求速度,可以把convertTo换成预处理一次性完成,但第一次跑通阶段不建议优化这个,先把结果对齐再说。
C++ 版本的可视化输出用cv::circle画关键点,或者把点集合用cv::polylines连成线。坐标换算和 Python 一样,注意输出网格数到原图像素坐标的比例关系即可。如果你发现 C++ 画的点和 Python 画的位置有偏差,先检查 resize 的插值方式是不是都是INTER_LINEAR,再检查颜色通道顺序,通常问题出在这两处。
5. 部署避坑手册:五个最容易翻车的位置与排查办法
5.1 检测结果错位严重,车道线满图乱飘
现象是跑了代码,图画出来了,但点全堆在图像的某个角落,或者线和真实车道完全对不上,看起来像随机噪声。
原因基本逃不出两个:一是预处理归一化写错了,比如只做了x/255-0.5忘了除以 0.5,模型输入的数值范围从[-1, 1]变成了[-0.5, 0.5],分布一偏,输出概率图全是乱的;二是 RGB/BGR 顺序反了,OpenCV 读图默认 BGR,模型训练时用的是 RGB,不转颜色空间,车道线特征全被交换了通道。判断方法很简单:把预处理后的图像保存一份,用numpy的mean和std看一下数值范围,如果不是均值接近 0、方差接近 1,归一化就有问题。
解决方式是严格复刻训练代码里的预处理,不要自创参数。UFLD-v2 的官方预处理就是(x / 255 - 0.5) / 0.5,RGB 顺序,resize 到 800×288。先把这一步做成一个独立函数,后续所有语言版本都调用同一逻辑,就能从源头避免错位。
5.2 C++ 运行即崩:Access Violation C0000005
现象是 C++ 程序编译通过,但运行到推理那一步直接报Access violation reading location,有时候是 0xC0000005,有时候是0x00000000,弹窗后程序退出。
原因大概率不是模型问题,而是内存生命周期管理失误。Ort::Value::CreateTensor是包装外部传入的数据指针,如果这个指针指向的内存已经被释放,session.Run在内部读取时就会触发非法访问。常见场景是把input_data放在一个局部作用域里,函数结束就销毁了,但input_tensor还持着这个地址。
解决方式是让输入数据的生命周期覆盖整个推理阶段,最简单粗暴的办法是把input_data声明在main函数里,或者包成一个类成员。另外,输出 tensor 拿到的float*指针所有权属于Ort::Value,不要在output_tensors析构后继续使用,需要拷贝到自己的容器里再处理后处理。
5.3 找不到 onnxruntime.dll 或报 0xc000007b
现象是 Windows 上编译成功后运行 exe,弹出“找不到 onnxruntime.dll”,或者错误码 0xc000007b,程序无法启动。
原因有两个层面:一是动态库搜索路径不包含 onnxruntime 所在目录,Windows 默认只搜 exe 同目录、系统目录和 PATH;二是缺少 Visual C++ Redistributable,onnxruntime 动态库本身依赖它,目标机器没装这个运行库,加载 dll 时架构或依赖校验失败,就报 0xc000007b。
解决方式是把onnxruntime.dll复制到 exe 所在的输出目录,然后在目标机器上安装对应架构的vc_redist.x64.exe。另有一个容易忽略的细节:如果你的 OpenCV 是动态链接的,opencv_world4xxx.dll也要一起带上,否则换一台电脑运行还会翻车。建议把onnxruntime.dll、OpenCV 的 dll 都放在 exe 同目录下,省得配 PATH。
5.4 C++ 结果和 Python 结果不一致
现象是同一张图,Python 版本检测结果正常,C++ 版本画出来的车道线有偏移,或者远处点明显偏少,但代码逻辑看起来一模一样。
原因多数出在预处理细节上。最常见的有两个:一是cv::resize的插值算法不一致,Python 端如果没指定interpolation,默认是INTER_LINEAR,而 C++ 端用了别的插值,两边的像素值就略有差异;二是使用cv::dnn::blobFromImage时,对输入图像的处理顺序和 Python 手写的transpose不同,blobFromImage参数里 swapRB、mean、scale 的组合容易计算错误。
解决方式是统一走 4.3 的手动预处理流程,不要依赖blobFromImage的默认行为。然后两边都保存预处理后的图像做对比,像素级差异应该在个位数以内,如果差很多就逐行检查 scale 和 shift 参数。这个排查方法也适用于把 Python 代码翻译成其他语言时。
5.5 模型输出 shape 和代码对不上
现象是打印出来的输出 shape 是[1, 4, 201, 1001],但代码里按[1, 4, 1001, 201]写,结果 softmax 的维度全错,画出来的点完全不对。
原因是你手里的 onnx 模型可能不是同一版本导出的。UFLD-v2 的 anchor 数量随输入分辨率变化,不同的导出脚本、不同的网络配置,输出的维度顺序也可能不同。很多教程里的代码都是针对特定模型写的,换个模型直接跑必然对不上。
解决方式是每次拿到新模型都先打印输入输出信息。Python 用session.get_outputs()[0].shape,C++ 用output_tensors[0].GetTensorTypeAndShapeInfo().GetShape(),把 shape 打印出来再调整代码里的常量。这个习惯应该固化成部署流程的第一步,不要默认任何模型和教程里的一样。
6. 跑通之后:三件事让检测更稳更快
6.1 单帧测速与线程数调优
跑通基础流程后,第一件事是测单帧延迟,确认你的硬件跑这个模型到底什么水平。测速时要注意 warmup 和循环次数,单帧时间受 CPU 降频、缓存预热影响很大,先跑 10 次预热再正式计时,取 100 次的平均耗时才可信。
auto start = std::chrono::high_resolution_clock::now(); auto output_tensors = session.Run(...); auto end = std::chrono::high_resolution_clock::now(); double ms = std::chrono::duration<double, std::milli>(end - start).count();线程数可以从 1 开始逐级往上试,找到你机器上的拐点。UFLD-v2 这种轻量模型,线程超过 4 个后性能提升非常有限,有时候反而下降。SetIntraOpNumThreads设置的只是单个算子内部的并行度,如果你跑的是多路视频流,建议把线程数调低一点,避免线程竞争,这样整体吞吐更高。
6.2 摄像头实时检测与车道线时序平滑
把单张图片换成摄像头视频流,核心改动是把推理放进一个循环里,每一帧都走一次预处理、推理、后处理。这里有一个参数层面的建议:视频分辨率通常不是 800×288,每帧都做全图 resize 没问题,但注意保持宽高比,不要为了省时间直接用cv::resize的最近邻插值,车道线对边缘敏感,最近邻会产生明显锯齿。
时序平滑是一个容易被忽略的细节。单帧检测在远处会抖动,因为远处车道线越靠近消失点,行分类的置信度越低,前后帧的 argmax 结果可能跳到相邻列。常见做法是做一个指数移动平均:lane_smooth = alpha * lane_current + (1 - alpha) * lane_prev,alpha取 0.6 到 0.7 之间。注意在弯道比较大的时候,平滑系数太大反而会让检测结果滞后,这个参数需要实测调整。
6.3 坐标映射与可视化进阶
最后聊一个可视化技巧。模型输出的行列索引映射到原图坐标时,很多人直接乘比例,但如果你是在摄像头画面里叠加车道线,原图可能经过裁剪,不是直接 resize 的关系。正确的做法是先记录从原图到模型输入的变换参数,比如缩放比例和偏移量,然后后处理出来的点做一次逆变换再画到原图上。资源包里的测试图是直接 resize 的,所以直接乘比例没问题,但换成视频流或任意分辨率时一定要补上这一步。
我在第一次部署 UFLD-v2 时也犯过 5.4 里的错,C++ 和 Python 结果对不上,排查了两天才发现是 blobFromImage 的 mean 算错了。从那以后我每次拿到模型都强制走一遍“打印输入输出 shape、对比预处理中间结果、核对归一化参数”这三步,之后再没被这类问题卡住。希望这些经验能帮你省下同样的弯路,祝你一次跑通。
本文还有配套的精品资源,点击获取