c++运行onnx模型
背景在通过量化过后我们将模型转化为onnx格式通常来说我们一般会将其先转化为onnx-fp32格式然后在次基础上通过校准图像转化为onnx-int8格式。转化好过后的fp32格式可能会含有两个文件model.onnx和model.onnx.data前者保存计算图、节点、张量引用、元信息后者保存实际的大块权重数据因此可以看出前者的内存大小远远小于data中的内存大小而fp32整个内存大小则是这两个文件内存大小之和其大约是int8格式的3-4倍甚至更小。其实按照量化的原理来说fp32权重为4bytes而int8为1bytes其内存大小应该为严格的四倍关系。但是在量化时我们通过会根据量化形式不同采用不同的方法来确保量化的精度。在QAT中通常插入伪量化节点PTQ中通常插入QDQ节点这些节点在转化后通过也会保留在权重信息中。同时一般为了保证精度的损失通常会不量化最后网络的输出层即最后一层。这些都会增加int8的内存大小。转化完成后下面便是转化为其他模型格式或者直接运行onnx格式。流程初始化环境-加载模型-提取并保持输入输出节点名称-数据预处理-创建输入张量以绑定已有内存-执行推理-解析输出结果其中最大的不同就是解析输出结果图像识别方面seg和obb的处理方式不同虽然都需要nms但是seg还需要掩膜重建而如果是TCN和transformer等则需要取最后一步输出等但是前面都是一样的主要是数据的预处理和输入张量形状要和训练时一样。初始化环境env_ make_uniqueOrt::Env(ORT_LOGGING_LEVEL_WARNING, YOLO_OBB_Inference); session_options_ make_uniqueOrt::SessionOptions(); // 单个算子最多并行线程数量设置 session_options_-SetIntraOpNumThreads(1); // 不同算子最多并行调度线程设置 session_options_-SetInterOpNumThreads(1); // 图优化 session_options_-SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_EXTENDED);创建Ort::Env-配置Ort::SessionOption-加载onnx创建Ort::Session-线程设置-图优化创建Ort::Envenv_ make_uniqueOrt::Env(ORT_LOGGING_LEVEL_WARNING, YOLO_OBB_Inference);创建一个 ONNX Runtime 的运行环境对象Ort::Env其负责 ONNX Runtime 的全局环境例如日志、线程池等。第一个参数日志级别含义适合场景ORT_LOGGING_LEVEL_VERBOSE最详细日志包含大量内部执行、优化、调试信息深度排查问题ORT_LOGGING_LEVEL_INFO普通信息日志比如初始化、加载、配置等调试阶段ORT_LOGGING_LEVEL_WARNING只打印警告及以上信息常用默认值ORT_LOGGING_LEVEL_ERROR只打印错误信息部署时想减少日志ORT_LOGGING_LEVEL_FATAL只打印严重致命错误极简日志正式部署可用但不利于排查第二个参数:日志名字配置Ort::SessionOption并创建Ort::Sessionsession_options_ make_uniqueOrt::SessionOptions();创建一个 ONNX Runtime 的 Session 配置对象它本身不加载模型只是用来配置后面创建Ort::Session时的参数。如果后续需要使用CPU/GPU等加速也需要在这里面配置环境。线程设置session_options_-SetIntraOpNumThreads(1); session_options_-SetInterOpNumThreads(1);IntraOp表示单个算子内部并行。InterOp表示不同算子并行计算。小模型 / 嵌入式 / 低延迟IntraOp 1 或 2InterOp 1大模型 / PC CPUIntraOp 物理核心数附近测试InterOp 1多模型并发每个 session 的 IntraOp 不要太大图优化session_options_-SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_EXTENDED);参数含义适合场景ORT_DISABLE_ALL关闭所有图优化调试、对比原始图ORT_ENABLE_BASIC基础优化保守部署ORT_ENABLE_EXTENDED基础优化 扩展融合优化常用ORT_ENABLE_LAYOUT进一步做 layout 相关优化某些后端/模型可能收益更大ORT_ENABLE_ALL开启所有可用优化通常部署首选加载模型// 加载模型 filesystem::path model_path_fs(path); session_ make_uniqueOrt::Session(*env_, model_path_fs.c_str(), *session_options_);加载模型时在linux系统下直接使用const sting来读取模型地址然后通过c_str()转化为提取并保存输入输出节点名称// 提取并保存输入输出节点名称 input_names_str_.clear(); output_names_str_.clear(); input_node_names_.clear(); output_node_names_.clear(); size_t input_count session_-GetInputCount(); size_t output_count session_-GetOutputCount(); input_names_str_.reserve(input_count); output_names_str_.reserve(output_count); input_node_names_.reserve(input_count); output_node_names_.reserve(output_count); for (size_t i 0; i input_count; i) { auto name_ptr session_-GetInputNameAllocated(i, allocator_); input_names_str_.emplace_back(name_ptr.get()); } for (size_t i 0; i output_count; i) { auto name_ptr session_-GetOutputNameAllocated(i, allocator_); output_names_str_.emplace_back(name_ptr.get()); } for (const auto name : input_names_str_) { input_node_names_.push_back(name.c_str()); } for (const auto name : output_names_str_) { output_node_names_.push_back(name.c_str()); }清空输入输出数据当该函数需要别反复初始化时通常需要使用clear将输入输出的节点名称清空以免后续的错误调用。提取模型输入输出个数并扩容输入输出容器通过GetInputCount和GetOutputCount来统计输入输出的个数再通过reserve将vector容器扩容以避免后续的参数过多时vector自动寻址扩容导致内存的零散和变更。提取输入输出节点名称通过上面得到的输入输出个数使用GetInputNameAllocated和GetInputNameAllocated提取出所有的输入输出提取出来的输入输出名称都是临时的只在该作用域下有效随后通过成员函数get得到string类型的输入输出名称并使用c_str转化为c风格的字符串。数据预处理数据预处理必须使用预训练时相同的数据预处理方法。在使用yolo进行图像处理时通常需要使用和yolo相同的预处理方法即首先进行等比例缩放再在四周填充灰色像素至模型输入尺寸然后对图像进行颜色通道、归一化、维度转化等处理。在使用LPRNet进行OCR字符预测时除了需要传入模型的位置、图像的长和宽还需要传入四个点的坐标以实现将图像裁剪为所需目标取再进行后续的预处理。在使用TCN进行时间序列预测时数据的预处理通常是序列整理、异常值处理、缺失值处理、归一化、滑动窗口、维度排列等。创建输入张量// 创建输入 Tensor绑定已有内存 auto memory_info Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, data_ptr, input_tensor_size, // 直接传 data_ptr input_shape.data(), input_shape.size() );创建储存在CPU中的张量Ort::MemoryInfo描述的是一块 Tensor 内存的来源和类型。而后续的成员函数CreatCpu则是在Cpu中创建张量。其本身并不会拷贝数据而是只创建一个张量放在指定的位置。第一个参数内存池分配器参数含义OrtArenaAllocator使用 ORT 的 arena 内存池分配器会复用内存减少频繁申请/释放OrtDeviceAllocator使用设备自己的普通分配器不走 arena 内存池OrtReadOnlyAllocator只读内存分配器通常和权重/常量相关普通输入不用第二个参数CPU内存类型参数含义OrtMemTypeDefault默认内存类型。CPU 推理时就是普通 CPU 内存OrtMemTypeCPUInput非 CPU 后端使用的 CPU 输入内存比如 GPU/NPU 推理时输入先在 CPU 上OrtMemTypeCPUOutput非 CPU 后端输出到 CPU 可访问内存将数据传入张量第一个参数描述数据所需要储存的内存所在通常是上文创建的张量第二个参数预处理后数据的首地址第三个参数元素个数不是字节数第四个参数输入张量的形状第五个参数输入张量的维度输入数据张量形状对于yolo和LPRNet模型来说其都是用以处理图像数据因此其输入数据张量形状为vectorint64_t input_shape { 1, channels, input_height, input_width };对于TCN模型来说其大多处理时间序列数据因此其输入数据张量形状为vectorint64_t input_shape { 1, feature_dim, sequence_length };执行推理// 执行推理 auto output_tensors session_-Run( Ort::RunOptions{ nullptr }, input_node_names_.data(), input_tensor, 1, output_node_names_.data(), output_node_names_.size() );参数个数例子含义1Ort::RunOptions{nullptr}本次推理的运行选项通常默认即可2input_node_names_.data()输入节点名称数组3input_tensor输入 Tensor 数组41输入 Tensor 数量5output_node_names_.data()要获取的输出节点名称数组6output_node_names_.size()输出节点数量解析输出结果解析输出结果的步骤会因为不同的模型而不同这也是推理中最为不同的一步。取出输出结果信息// 解析输出结果 // 取出第i个输出张量的类型和形状信息 auto output_info output_tensors[i].GetTensorTypeAndShapeInfo(); // 取得输出张量的形状 vectorint64_t output_shape output_info.GetShape(); // 取得输出张量的数据类型 ONNXTensorElementDataType dtype type_info.GetElementType(); // 取得第i个输出张量的真实数据指针 float* output_data output_tensors[i].GetTensorMutableDatafloat();常见的模型中yolo-seg和transformer等模型都是多输出模型因此需要使用for循环来提取所有的输出信息但是如果后续不适用某一部分的输出信息可以不提取以减轻后处理的复杂度。如果无法判断模型是否为多输出可以将使用for循环将这些输出信息打印出来以直观判断。在取得第i个输出张量得真实数据指针中其取出得数据指针类型必须要和输出张量的数据类型相同。其中ONNXTensorElementDataType 的数据类型如下ONNXTensorElementDataTypec类型ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOATfloat32ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16float16ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64int64ONNX_TENSOR_ELEMENT_DATA_TYPE_INT32int32ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT8int8图像处理方面图像处理方面通常都是使用yolo进行处理目前较为常用的为目标检测、分类检测、旋转框检测和掩膜检测。这里主要说明旋转框检测和掩膜检测。判断输出排列格式无论是那种yolo模型首先都需要判断输出的排列格式。if (output_shape[1] output_shape[2]) { num_anchors static_castint(output_shape[1]); num_channels static_castint(output_shape[2]); is_transposed true; } else { num_channels static_castint(output_shape[1]); num_anchors static_castint(output_shape[2]); }yolo-obb旋转框检测通常输出一个候选框矩阵[batch, C, N]或[batch, N, C]通常为[batch, C, N]但是都需要先判断一下输出的排列格式再进行后续的推理。其中C4类别数1第一个参数batch候选框第二个参数C前四个表示候选框的cx,vy,w,h即中心点的xy坐标和检测框的长、宽。中间为模型的类别分数最后的候选框的角度。第三个参数N模型原始输出的候选框数量。需要经过置信度过滤和nms才能得到最终的输出目标数量。yolo-segyolo-seg通常有两个输出output0:[1, 4 类别数 mask_dim, N]和output1:[1,mask_dim, mask_h, mask_w]output0第一个参数检测框第二个参数前四个表示检测框的cx,vy,w,h即中心点的xy坐标和检测框的长、宽。中间为模型的类别分数最后的mask_dim表示mask系数即mask原型图的通道数也是每个候选框携带的mask系数数量。计算mask_dim直接使用output1的第二个参数即可。第三个参数模型原始输出的候选框数量。需要经过置信度过滤和nms才能得到最终的输出目标数量。output1第一个参数检测框第二个参数mask系数通常都是32。第三个参数掩膜的高第四个参数掩膜的宽获得取值函数通过上面判断出的排列格式构建一个函数用以后续的取值函数。int num_classes num_channels - 5; // yolo-obb int num_classes num_channels - 4 - 32; // yolo-seg auto get_value [](int channel_idx, int anchor_idx) - float { if (is_transposed) { return output_data[anchor_idx * num_channels channel_idx]; } else { return output_data[channel_idx * num_anchors anchor_idx]; } };置信度阈值过滤vectorOBBDetection detections; vectorSEGTempDetection detections; detections.reserve(num_anchors); for (int i 0; i num_anchors; i) { float max_conf 0.0f; int max_class_id -1; for (int c 0; c num_classes; c) { float conf get_value(4 c, i); if (conf max_conf) { max_conf conf; max_class_id c; } } if (max_conf conf_threshold) { // 后续可能不同需要根据自己的需求使用更改代码 OBBDetection det; det.cx get_value(0, i); det.cy get_value(1, i); det.w get_value(2, i); det.h get_value(3, i); det.angle get_value(4 num_classes, i); det.class_id max_class_id; det.confidence max_conf; detections.push_back(det); } }这里获取候选框的第几个参数需要结合上面的输出排列格式来判断。这里得到的detections是所有种类只要满足置信度的候选框。非极大值抑制(nms)其主要使用在目标检测中当模型识别到一个物体时会有一堆高度重叠的候选框nms便是在这些高度重叠的候选框中保留最可信的一个同时去掉其余的候选框。步骤按confidence从高到底排列(sort)-取分数最高的框进行保留-计算它与其他框的IoU-判断IoU是否满足阈值首先将解析好的候选框按从大到小的顺序排列。然后定义一个是否过滤的容器将所有候选框都定义为可以保留即不过滤的候选框值为false其大小与候选框大小相同。第一个即置信度最高的候选框直接保留无需其他验证。遍历剩下的候选框与当前的候选框计算IoU并比较阈值。若IoU大于阈值则表示两框重叠太多该候选框为相同种类不同物体不修改容器值若IoU小于等于阈值则表示两框重叠较少该候选框为相同种类相同物体修改容器值为true。在第一次遍历结束后其主要修改的是候选框与置信度最高的候选框之间的IoU还需要将剩下的候选框再次作比较最后得出所有可能的候选框以输出。// NMS // 按置信度排列 sort(detections.begin(), detections.end(), [](const OBBDetection a, const OBBDetection b) { return a.confidence b.confidence; }); vectorOBBDetection nms_result; vectorbool is_suppressed(detections.size(), false); for (size_t i 0; i detections.size(); i) { // 取分数最高的框进行保留其余分数在下面的代码中通过IoU可能会被值为true直接跳过 if (is_suppressed[i]) continue; nms_result.push_back(detections[i]); // 遍历除去第一个参数也就是最高分候选框 for (size_t j i 1; j detections.size(); j) { if (is_suppressed[j]) continue; // 判断种类是否一致 if (detections[i].class_id detections[j].class_id) { // 通过旋转IoU与IoU阈值比较得出其是否为同一物体 // 如果后续有不满足阈值的则表示候选框为相同种类不同物体 // 后续为满足阈值的则表示候选框为相同种类相同物体 float iou CalculateRotatedIoU(detections[i], detections[j]); float iou CalculateBoxIoU(detections[i], detections[j]); if (iou nms_iou_threshold) { is_suppressed[j] true; } } } }Rotated NMS// 计算两个 OBB 的 Rotated IoU float CalculateRotatedIoU(const OBBDetection a, const OBBDetection b) { // 将弧度转换为角度 float angle_a_deg a.angle * 180.0f / CV_PI; float angle_b_deg b.angle * 180.0f / CV_PI; // 构建旋转矩形 cv::RotatedRect rect_a(cv::Point2f(a.cx, a.cy), cv::Size2f(a.w, a.h), angle_a_deg); cv::RotatedRect rect_b(cv::Point2f(b.cx, b.cy), cv::Size2f(b.w, b.h), angle_b_deg); // 计算多边形交集点 std::vectorcv::Point2f intersecting_region; int intersection_type cv::rotatedRectangleIntersection(rect_a, rect_b, intersecting_region); // 如果没有任何交集IoU 为 0 if (intersection_type cv::INTERSECT_NONE || intersecting_region.empty()) { return 0.0f; } // 计算交集多边形的面积 float inter_area cv::contourArea(intersecting_region); // 计算各自的面积 float area_a a.w * a.h; float area_b b.w * b.h; // 计算 IoU 交集 / 并集 float iou inter_area / (area_a area_b - inter_area); return iou; }yolo-obb在nms中最大的不同便是IoU的计算为Rotated IoU。其步骤如上述代码所示。Box NMSfloat CalculateBoxIoU(const SEGTempDetection a, const SEGTempDetection b) { float ax1 a.cx - a.w * 0.5f; float ay1 a.cy - a.h * 0.5f; float ax2 a.cx a.w * 0.5f; float ay2 a.cy a.h * 0.5f; float bx1 b.cx - b.w * 0.5f; float by1 b.cy - b.h * 0.5f; float bx2 b.cx b.w * 0.5f; float by2 b.cy b.h * 0.5f; float inter_x1 std::max(ax1, bx1); float inter_y1 std::max(ay1, by1); float inter_x2 std::min(ax2, bx2); float inter_y2 std::min(ay2, by2); float inter_w std::max(0.0f, inter_x2 - inter_x1); float inter_h std::max(0.0f, inter_y2 - inter_y1); float inter_area inter_w * inter_h; float area_a std::max(0.0f, a.w) * std::max(0.0f, a.h); float area_b std::max(0.0f, b.w) * std::max(0.0f, b.h); return inter_area / (area_a area_b - inter_area 1e-6f); }为了节约算力通常seg计算IoU不使用掩膜进行计算直接使用Box NMS该计算方法也是传统目标检测时使用的nms计算方法。只有一些特殊后处理或精细化算法才会用 mask IoU。后处理后处理需要根据自己的需求改变解析出来的值比如旋转角是传角度还是弧度夹取物体时是否需要使用掩膜计算出不规则物体的质心而非中心点以及一个坐标的还原等。yolo-obbcv::RotatedRect rRect(cv::Point2f(real_cx, real_cy), cv::Size2f(real_w, real_h), angle_deg);通常可以使用上述代码构建出旋转矩形以可视化。yolo-seg--二值掩膜由于上述nms直接使用Box NMS来输出的候选框而如果需要还原预测物体的整体形状则需要使用二值掩膜。二值掩膜二值掩膜是为了更为精确的描述目标的轮廓区域。通过yolo-seg预测出来的图像其会生成一个掩膜矩阵其每一个像素点都是0-1之间的某个概率通过一个阈值将其变化为二值结果即一张0和1的mask图用以表示那些像素属于目标那些像素不属于目标。再置信度过滤和nms后保留下来的目标中拿到对应目标的掩膜矩阵相当于output1去掉最高维然后再通过output0拿到对应目标的掩模系数32×N现在的N由于置信度过滤和nms其值已经非常小然后将两个取得的矩阵加权求和并做归一化最后resize回原图大小同时取出其有效位置并做阈值处理得出最后的二值掩膜矩阵。cv::Mat BuildMaskBinary( const std::vectorfloat mask_coeff, Ort::Value proto_tensor, int input_width, int input_height, const cv::Rect box, float mask_threshold 0.5f) { // 创建二值掩膜矩阵 cv::Mat mask_binary cv::Mat::zeros(input_height, input_width, CV_8UC1); // 取出output1 auto proto_info proto_tensor.GetTensorTypeAndShapeInfo(); std::vectorint64_t proto_shape proto_info.GetShape(); // output1不是4维或者掩膜系数为空 if (proto_shape.size() ! 4 || mask_coeff.empty()) { return mask_binary; } // 取得掩膜系数维度 int mask_dim static_castint(mask_coeff.size()); // 判断输出格式 bool is_chw proto_shape[1] mask_dim; bool is_hwc proto_shape[3] mask_dim; if (!is_chw !is_hwc) { return mask_binary; } // 取出掩膜高和宽并计算矩阵框面积 int mask_h is_chw ? static_castint(proto_shape[2]) : static_castint(proto_shape[1]); int mask_w is_chw ? static_castint(proto_shape[3]) : static_castint(proto_shape[2]); int mask_area mask_h * mask_w; // 取出掩膜系数的首地址 float* proto_data proto_tensor.GetTensorMutableDatafloat(); // 构建掩膜系数矩阵 cv::Mat mask_prob(mask_h, mask_w, CV_32FC1); // 对每个像素位置把32个基础掩膜值掩膜宽×掩膜高按32个掩膜系数加权求和 for (int y 0; y mask_h; y) { // 取得矩阵第y行数据 float* row mask_prob.ptrfloat(y); for (int x 0; x mask_w; x) { float value 0.0f; for (int k 0; k mask_dim; k) { float proto_value 0.0f; // 第k个掩模通道的第y行第x列 if (is_chw) { // [batch, height, weight] proto_value proto_data[k * mask_area y * mask_w x]; } else { // [height, weight, batch] proto_value proto_data[(y * mask_w x) * mask_dim k]; } // 加权求和 value mask_coeff[k] * proto_value; } // 第y行第x列数据归一化到[0, 1] row[x] sigmoid(value); } } // resize回原图大小 cv::Mat mask_resized; cv::resize(mask_prob, mask_resized, cv::Size(input_width, input_height), 0, 0, cv::INTER_LINEAR); // 裁剪有效目标框 cv::Rect image_rect(0, 0, input_width, input_height); cv::Rect valid_box box image_rect; if (valid_box.width 0 || valid_box.height 0) { return mask_binary; } // 仅获取目标区域掩膜得到概率图 cv::Mat roi_prob mask_resized(valid_box); cv::Mat roi_binary_float; // 二值化并写入mask_binary cv::threshold(roi_prob, roi_binary_float, mask_threshold, 255.0, cv::THRESH_BINARY); roi_binary_float.convertTo(mask_binary(valid_box), CV_8UC1); return mask_binary; }上述代码仅针对于单张图像而言如果有多张图像需要加入不同图像的偏移OCR字符识别--LPRNet输出排列格式LPRNet创建输出为三维矩阵[1, 字符串类别数, 序列长度]或[1, 序列长度, 字符串别数]1批次数量字符串类别数所有字符串类别数量通常包含空白字符序列长度模型把车牌横向分成多少个识别位置这个输出维度必须和模型的预训练中相同if (output_shape.size() ! 3) { return ; } int dim1 static_castint(output_shape[1]); int dim2 static_castint(output_shape[2]); int num_classes 0; int sequence_length 0; bool class_first false; if (dim1 static_castint(charset.size())) { num_classes dim1; sequence_length dim2; class_first true; } else if (dim2 static_castint(charset.size())) { num_classes dim2; sequence_length dim1; class_first false; } else { return ; }准备字符表模型输出的是字符编号而非直接输出汉字或字母因此需要定义一个字符表而这个字符表的定义也必须和模型的预训练中相同。这个字符表的长度也是上面输出排列格式中字符串类别数的长度其中不一定含有空白字符这需要根据你预训练时来更改。// 准备字符表 vectorstring charset { 京,沪,津,渝,冀,晋,蒙,辽,吉,黑, 苏,浙,皖,闽,赣,鲁,豫,鄂,湘,粤, 桂,琼,川,贵,云,藏,陕,甘,青,宁,新, 0,1,2,3,4,5,6,7,8,9, A,B,C,D,E,F,G,H,J,K,L,M, N,P,Q,R,S,T,U,V,W,X,Y,Z, 0,- };找出空白字符编码LPRNet通常使用类似连接时序分类的解码方式所以字符表里通常会有一个空白字符。空白字符不是车牌内容只是模型用来表示该位置没有明确字符。// 找出空白字符编码 int blank_index static_castint(charset.size()) - 1;如上述字符表中最后一个元素-便是空白字符。CTC解码每个序列取最大分数字符LPRNet预测是将整个车牌分成“序列长度”的长度每个“序列长度”都对应了“字符串类别数”个值每个“字符串类别数”都是一个概率因此需要在每个“序列长度”位置都选取分数最高的以一个字符串。去掉连续重复字符去掉空白字符去掉空白字符是max_class_id ! blank_index去掉连续重复字符是max_class_id ! last_class_id如果没有很好的理解这个原理可能会觉得如果车牌为连续数字888这种如果直接去掉连续字符会导致类似于这样的连续重复字符无法输出甚至直接导致车牌位数的缺失。但其实last_class_id是根据max_class_id来改变的而每个字符之间一定存在一个空白字符类似于上面的888车牌其实真实模型的输出应该为88空白88空白8...因此并不会出现上述车牌位数缺失的问题。按字符表转成字符串时间序列预测--TCNTCN主要做取的事情是分类、回归预测、序列预测其基本都是通过TCN神经网络来拟合一段数据以预测后续数据因此其后处理通常都十分简单。这里我主要讲解器序列预测的输出格式。输出排列格式TCN创建输出为三维序列张量[batch, feature_dim, sequence_length]batch:数据批次feature_dim:预测特征数量sequence_length:连续时间点获取输出数据元素总数// 获取输出元素总数 size_t output_size 1; for (auto dim : output_shape) { output_size * static_castsize_t(dim); }获取输出数据// 获取输出数据 vectorfloat result(output_size); for (size_t i 0; i output_size; i) { result[i] output_data[i]; }总结虽然各个模型之间的后处理不同但是其总体的环境设置、推理等都是一样。而相同模型的不同格式则恰好相反其后处理基本不变但是其模型的环境、推理则不一样。同时不同模型格式还需要注意在量化模型时是否将nms等这些算法直接嵌入进模型了如果嵌入则后续无需使用nms来进行后处理。同时不同格式的模型输出格式也可能不同这也是我上面在讲解输出排列格式是使用了两个情况的原因。因此模型不同也好格式不同也好都需要以实际输出内容为主需要根据不同的嵌入式设备、模型导出格式等来进行调整。