首页
/ OpenCV DNN 自定义层(Custom Layers)完全指南:从 C++、ONNX 到 Python 的模型导入扩展

OpenCV DNN 自定义层(Custom Layers)完全指南:从 C++、ONNX 到 Python 的模型导入扩展

2026-09-06 17:35:21作者:裘晴惠Vivianne

本文基于 OpenCV 官方教程 dnn_custom_layers,系统讲解当 cv::dnn 遇到未实现的算子时,如何通过继承 cv::dnn::Layer、注册到 LayerFactory 来扩展 OpenCV 深度学习引擎。读完本文,你将掌握自定义层五个核心方法(构造函数、creategetMemoryShapesforwardfinalize)的职责与调用时序,并能够针对 TensorFlow、ONNX 和 Python 三种场景写出可运行的自定义层代码,使 OpenCV 成功导入原本会被拒绝的网络模型。

1. 为什么需要自定义层

深度学习领域增长迅速,新的网络架构不断引入新的层类型——既可能是对现有层的修改,也可能是全新研究思想的落地。OpenCV 支持从多个主流深度学习框架导入并运行网络,并内置了大量最常见的层。但当你的模型包含 OpenCV 深度学习引擎尚未实现的算子时,导入会失败。此时有两条路径:

  1. 向上游提需求:在 OpenCV 官方 issue 渠道发起 feature request,附上模型来源与未实现算子的类型。若社区有同样需求,该层就有可能被实现;
  2. 定义自定义层(custom layer):让 OpenCV 的深度学习引擎自己知道如何"使用"这个算子——这正是本指南的主题,核心是模型导入的定制化(customization of model import)

1.1 层的基本构成

一个深度学习层是网络流水线的构建块,具备以下要素:

  • 连接到若干输入 blob(input blobs);
  • 将计算结果写入输出 blob(output blobs);
  • 拥有训练得到的权重(weights)与超参数(hyper-parameters);
  • 层的名称、类型、权重与超参数都保存在由各框架在训练后生成的模型文件中。

当 OpenCV 在读取模型时遇到无法识别的层类型,会抛出异常:

Unspecified error: Can't create layer "layer_name" of type "MyType" in function getLayerInstance

异常信息中的类型名(上例的 MyType)就是你后续注册自定义层时必须使用的键名。

2. C++ 中定义自定义层:接口契约

要正确导入模型,需要从一个继承 cv::dnn::Layer 的派生类开始,实现如下接口:

class MyLayer : public cv::dnn::Layer
{
public:
    MyLayer(const cv::dnn::LayerParams &params);

    static cv::Ptr<cv::dnn::Layer> create(cv::dnn::LayerParams& params);

    virtual bool getMemoryShapes(const std::vector<std::vector<int> > &inputs,
                                 const int requiredOutputs,
                                 std::vector<std::vector<int> > &outputs,
                                 std::vector<std::vector<int> > &internals) const CV_OVERRIDE;

    virtual void forward(cv::InputArrayOfArrays inputs,
                         cv::OutputArrayOfArrays outputs,
                         cv::OutputArrayOfArrays internals) CV_OVERRIDE;

    virtual void finalize(cv::InputArrayOfArrays inputs,
                          cv::OutputArrayOfArrays outputs) CV_OVERRIDE;
};

上述接口声明与仓库中的示例头文件 samples/dnn/custom_layers.hpp 一致。注册则在导入模型之前完成:

#include <opencv2/dnn/layer.details.hpp>  // CV_DNN_REGISTER_LAYER_CLASS

static inline void loadNet()
{
    CV_DNN_REGISTER_LAYER_CLASS(MyType, MyLayer);
    cv::dnn::Net net = cv::dnn::readNetFromTensorflow("/path/to/graph.pb");
}

注意:MyType 必须与读取模型时抛出的异常中的未实现层类型完全一致。

从源码结构看,CV_DNN_REGISTER_LAYER_CLASS 宏定义在 layer.details.hpp,其本质是调用 cv::dnn::LayerFactory::registerLayer(#type, ...),把"类型名字符串 → 构造器"的映射登记进全局工厂;配套的 CV_DNN_REGISTER_LAYER_FUNC(注册任意构造函数)与 ..._STATIC 系列宏(在模块加载时即完成注册、析构时自动注销)也在同一头文件中提供。OpenCV 内置层正是通过同一机制注册的,例如 init.cpp 中对 ConcatInterpFlatten 等层的批量注册——自定义层与内置层走的是完全相同的工厂路径。

2.1 五个方法各自的职责

(1)构造函数

MyLayer(const cv::dnn::LayerParams &params);

cv::dnn::LayerParams 中提取超参数。若该层带有可训练权重,它们在构造时已经存放在基类成员 cv::dnn::Layer::blobs 中。仓库示例 samples/dnn/custom_layers.hpp 中的 InterpLayer 就是典型写法:

InterpLayer(const cv::dnn::LayerParams &params) : Layer(params)
{
    outWidth  = params.get<int>("width", 0);
    outHeight = params.get<int>("height", 0);
}

(2)静态方法 create

static cv::Ptr<cv::dnn::Layer> create(cv::dnn::LayerParams& params);

创建你的层实例并返回 cv::Ptr。示例实现(samples/dnn/custom_layers.hpp):

static cv::Ptr<cv::dnn::Layer> create(cv::dnn::LayerParams& params)
{
    return cv::Ptr<cv::dnn::Layer>(new InterpLayer(params));
}

(3)输出 blob 形状计算 getMemoryShapes

virtual bool getMemoryShapes(const std::vector<std::vector<int> > &inputs,
                             const int requiredOutputs,
                             std::vector<std::vector<int> > &outputs,
                             std::vector<std::vector<int> > &internals) const CV_OVERRIDE;

根据输入形状计算输出形状;如需要额外缓冲,可通过 internals 申请。InterpLayer 的示例实现(samples/dnn/custom_layers.hpp)保留批大小与通道数、把空间维度替换为超参数指定的目标宽高:

std::vector<int> outShape(4);
outShape[0] = inputs[0][0];  // batch size
outShape[1] = inputs[0][1];  // number of channels
outShape[2] = outHeight;
outShape[3] = outWidth;
outputs.assign(1, outShape);
return false;

(4)执行层逻辑 forward

virtual void forward(cv::InputArrayOfArrays inputs,
                     cv::OutputArrayOfArrays outputs,
                     cv::OutputArrayOfArrays internals) CV_OVERRIDE;

在这里实现层的核心计算:给定输入,算出输出。InterpLayerforward 实现了双线性插值(samples/dnn/custom_layers.hpp),其计算方式参考了 Caffe 的 interp_layer 实现,即对每个目标像素反查源图像坐标,用上下左右四个邻居按权重加权合成。

内存管理注意:OpenCV 统一管理层的内存,多数情况下同一片内存会在各层之间复用。因此 forward 的实现不能假设第二次调用时 outputsinternals 中仍是上一次的数据——每次调用都必须从 inputs 完整计算出结果。

(5)可选的 finalize

virtual void finalize(cv::InputArrayOfArrays inputs,
                      cv::OutputArrayOfArrays outputs) CV_OVERRIDE;

完整调用链是:OpenCV 深度学习引擎先调用 create 一次 → 然后对每个创建的层调用 getMemoryShapes → 随后你可以在 finalize 中依据已知的输入维度做一些准备 → 网络初始化完成后,每次推理只调用 forward

ResizeBilinearLayer 展示了 finalize 的一个真实用途(samples/dnn/custom_layers.hpp):当模型只给出缩放因子而未给出绝对输出尺寸时,getMemoryShapes 阶段尚无法确定目标宽高,于是推迟到 finalize 中从已分配的输出 Mat 里回填:

virtual void finalize(cv::InputArrayOfArrays, cv::OutputArrayOfArrays outputs_arr) CV_OVERRIDE
{
    std::vector<cv::Mat> outputs;
    outputs_arr.getMatVector(outputs);
    if (!outWidth && !outHeight)
    {
        outHeight = outputs[0].size[2];
        outWidth  = outputs[0].size[3];
    }
}

性能提示:输入 blob 的高、宽或 batch size 一旦变化,OpenCV 会重新分配所有内部内存,造成效率损失。建议初始化和部署模型时尽量使用固定的 batch size 与图像尺寸

3. 示例一:导入包含 TensorFlow resize_bilinear 的网络

本例演示导入一个包含 tf.image.resize_bilinear 操作的单图层网络(注意:它同样做缩放,但实现方式与 OpenCV 内置的 resize 不同):

inp = tf.placeholder(tf.float32, [2, 3, 4, 5], 'input')
resized = tf.image.resize_bilinear(inp, size=[9, 8], name='resize_bilinear')

OpenCV 看到的 TensorFlow 计算图如下(protobuf 文本表示):

node {
  name: "input"
  op: "Placeholder"
  attr {
    key: "dtype"
    value { type: DT_FLOAT }
  }
}
node {
  name: "resize_bilinear/size"
  op: "Const"
  attr {
    key: "dtype"
    value { type: DT_INT32 }
  }
  attr {
    key: "value"
    value {
      tensor {
        dtype: DT_INT32
        tensor_shape { dim { size: 2 } }
        tensor_content: "\t\000\000\000\010\000\000\000"
      }
    }
  }
}
node {
  name: "resize_bilinear"
  op: "ResizeBilinear"
  input: "input:0"
  input: "resize_bilinear/size"
  attr {
    key: "T"
    value { type: DT_FLOAT }
  }
  attr {
    key: "align_corners"
    value { b: false }
  }
}

自定义层导入 TensorFlow 模型的约定是:该层的所有 attr 全部放进 cv::dnn::LayerParams,而输入的 Const blob 则放进 cv::dnn::Layer::blobs。在本例中,resize 的输出尺寸 [9, 8] 就存放在 blobs[0] 中。据此实现的自定义层如下(完整代码见 samples/dnn/custom_layers.hpp):

class ResizeBilinearLayer CV_FINAL : public cv::dnn::Layer
{
public:
    ResizeBilinearLayer(const cv::dnn::LayerParams &params) : Layer(params)
    {
        CV_Assert(!params.get<bool>("align_corners", false));
        CV_Assert(!blobs.empty());

        for (size_t i = 0; i < blobs.size(); ++i)
            CV_Assert(blobs[i].type() == CV_32SC1);

        // 两种输入 blob 情况:单个 blob 存输出形状,或两个 blob 存缩放因子。
        if (blobs.size() == 1)
        {
            CV_Assert(blobs[0].total() == 2);
            outHeight = blobs[0].at<int>(0, 0);
            outWidth  = blobs[0].at<int>(0, 1);
            factorHeight = factorWidth = 0;
        }
        else
        {
            CV_Assert(blobs.size() == 2);
            CV_Assert(blobs[0].total() == 1); CV_Assert(blobs[1].total() == 1);
            factorHeight = blobs[0].at<int>(0, 0);
            factorWidth  = blobs[1].at<int>(0, 0);
            outHeight = outWidth = 0;
        }
    }

    static cv::Ptr<cv::dnn::Layer> create(cv::dnn::LayerParams& params)
    {
        return cv::Ptr<cv::dnn::Layer>(new ResizeBilinearLayer(params));
    }

    virtual bool getMemoryShapes(const std::vector<std::vector<int> > &inputs,
                                 const int,
                                 std::vector<std::vector<int> > &outputs,
                                 std::vector<std::vector<int> > &) const CV_OVERRIDE
    {
        std::vector<int> outShape(4);
        outShape[0] = inputs[0][0];  // batch size
        outShape[1] = inputs[0][1];  // number of channels
        outShape[2] = outHeight != 0 ? outHeight : (inputs[0][2] * factorHeight);
        outShape[3] = outWidth  != 0 ? outWidth  : (inputs[0][3] * factorWidth);
        outputs.assign(1, outShape);
        return false;
    }

    virtual void finalize(cv::InputArrayOfArrays, cv::OutputArrayOfArrays outputs_arr) CV_OVERRIDE
    {
        std::vector<cv::Mat> outputs;
        outputs_arr.getMatVector(outputs);
        if (!outWidth && !outHeight)
        {
            outHeight = outputs[0].size[2];
            outWidth  = outputs[0].size[3];
        }
    }

    // 参考 TensorFlow Lite 的 reference_ops 实现
    virtual void forward(cv::InputArrayOfArrays inputs_arr,
                         cv::OutputArrayOfArrays outputs_arr,
                         cv::OutputArrayOfArrays internals_arr) CV_OVERRIDE
    {
        if (inputs_arr.depth() == CV_16S)
        {
            // DNN_TARGET_OPENCL_FP16 场景:转 FP32 后递归回调本 forward
            forward_fallback(inputs_arr, outputs_arr, internals_arr);
            return;
        }
        // ... 双线性插值计算(省略,见源文件)
    }
    // ...
};

随后注册层并尝试导入模型:

CV_DNN_REGISTER_LAYER_CLASS(ResizeBilinear, ResizeBilinearLayer);
cv::dnn::Net tfNet = cv::dnn::readNet("/path/to/graph.pb");

从源码结构还可以看到两个工程细节:其一,forward 开头的 CV_16S 分支调用 forward_fallback,说明在 FP16 目标下引擎会自动转成 FP32 再回调同一 forward,自定义层无需为半精度单独写代码;其二,InterpLayercustom_layers.hpploadNet 示例中演示了完整的"注册 → cv::dnn::readNet(prototxt, caffemodel) 导入 Caffe 模型"流程,与 TensorFlow 路径形成对照。

4. 示例二:导入包含自定义 ONNX 算子的网络

ONNX 把算子组织成 domains(域):标准算子位于默认域 ai.onnx;厂商与导出器常把自己的算子放进命名域,例如 my.namespace。OpenCV 导入 ONNX 节点时,按如下规则在 cv::dnn::LayerFactory 中查找算子:

  • 默认 ai.onnx 域(或未指定域)的节点:直接用 op_type 查找;
  • 任何非默认域的节点:用 "<domain>.<op_type>" 组合键查找。

节点属性(attributes)会以同名键透传给层构造器的 cv::dnn::LayerParams

假设有一个计算 y = scale * x + bias 的算子 MyCustomOp,带属性 scalebias,实现如下(可运行示例见 samples/dnn/custom_layer_onnx.cpp):

// y = scale * x + bias,scale/bias 从 ONNX 节点属性读取
class CustomScaleBiasLayer CV_FINAL : public Layer
{
public:
    CustomScaleBiasLayer(const LayerParams& params) : Layer(params)
    {
        scale = params.get<float>("scale", 1.f);
        bias  = params.get<float>("bias",  0.f);
    }

    static Ptr<Layer> create(LayerParams& params)
    {
        return makePtr<CustomScaleBiasLayer>(params);
    }

    bool getMemoryShapes(const vector<MatShape>& inpts,
                         const int /*requiredOutputs*/,
                         vector<MatShape>& outShapes,
                         vector<MatShape>& /*internals*/) const CV_OVERRIDE
    {
        outShapes.assign(1, inpts[0]);
        return false;
    }

    void forward(InputArrayOfArrays inputs_arr, OutputArrayOfArrays outputs_arr,
                 OutputArrayOfArrays) CV_OVERRIDE
    {
        vector<Mat> inps, outs;
        inputs_arr.getMatVector(inps);
        outputs_arr.getMatVector(outs);
        inps[0].convertTo(outs[0], outs[0].type(), scale, bias);
    }

private:
    float scale, bias;
};

导入使用该算子的模型时,必须在调用 cv::dnn::readNetFromONNX 之前注册层。使用 cv::dnn::LayerFactory::registerLayer 做运行时注册(用完可调用 cv::dnn::LayerFactory::unregisterLayer 注销),并按算子所在域选择正确的键samples/dnn/custom_layer_onnx.cpp):

// 默认 ai.onnx 域:注册键就是 op_type,例如 "MyCustomOp"
// 自定义域:注册键为 "<domain>.<op_type>",例如 "my.namespace.MyDomainOp"
LayerFactory::registerLayer(opKey, CustomScaleBiasLayer::create);

Net net = readNetFromONNX(modelPath);
// ... 推理 ...
LayerFactory::unregisterLayer(opKey);

该示例通过命令行参数区分两种注册路径:

--model <path>    包含自定义算子的 ONNX 模型路径
--op    <key>     注册键。默认域只写 op_type(如 MyCustomOp);
                  自定义域写 <domain>.<op_type>(如 my.namespace.MyDomainOp)

从源码结构看,这套"域 → 键"机制与 ONNX 导入器内部保持一致:onnx_importer2.cpp 中维护了 ai.onnxai.onnx.previewcom.microsoft 等域的 opset 解析逻辑,对无法解析的节点会提示通过 CV_DNN_REGISTER_LAYER_CLASS()LayerFactory::registerLayer() 注册处理器(见该文件约 L1141 附近的错误提示)。配套的微型 ONNX 测试模型(分别覆盖默认域与自定义域两条注册路径)由 opencv_extra 仓库中的 testdata/dnn/onnx/generate_custom_layer_models.py 脚本生成,输入规格为 1x3x4x4 的 float 张量,可在 custom_layer_onnx.cpp 中对照验证。

5. 示例三:Python 中替换/新增自定义层

Python 侧的定制 API 与 C++ 同构但更简洁。以 Holistically-Nested Edge Detection(HED) 模型为例:其中的 Crop 层接收两个输入 blob,把第一个裁剪到与第二个空间维度一致。OpenCV 内置的 Crop 层从左上角裁剪,而该模型期望居中裁剪——直接沿用内置行为会产生带填充边界的偏移结果。因此我们用一个居中裁剪的自定义层替换 OpenCV 内置的 Crop 层。

(1)创建带 getMemoryShapesforward 方法的类(完整代码见 samples/dnn/custom_layer.py):

import cv2 as cv

class CropLayer(object):
    def __init__(self, params, blobs):
        self.xstart = 0
        self.xend = 0
        self.ystart = 0
        self.yend = 0

    # 层接收两个输入:把第一个输入 blob 裁剪到与第二个
    # 形状一致(保留 batch size 与通道数)
    def getMemoryShapes(self, inputs):
        inputShape, targetShape = inputs[0], inputs[1]
        batchSize, numChannels = inputShape[0], inputShape[1]
        height, width = targetShape[2], targetShape[3]

        self.ystart = (inputShape[2] - targetShape[2]) // 2
        self.xstart = (inputShape[3] - targetShape[3]) // 2
        self.yend = self.ystart + height
        self.xend = self.xstart + width

        return [[batchSize, numChannels, height, width]]

    def forward(self, inputs):
        return [inputs[0][:,:,self.ystart:self.yend,self.xstart:self.xend]]

注意:两个方法都必须返回列表

(2)注册新层samples/dnn/custom_layer.py):

cv.dnn_registerLayer('Crop', CropLayer)

至此,我们就把一个已实现的 OpenCV 内置层替换成了自定义实现。该注册 API 在 Python 绑定中的声明可参见 cv2.cpp{"dnn_registerLayer", ..., "registerLayer(type, class) -> None"}

完整推理脚本见仓库中的 samples/dnn/edge_detection.py。该脚本支持 Canny 与深度学习(Dexined)两种边缘检测方法的切换,关键调用是 cv.dnn.readNetFromONNX(args.model, engine) 加载模型、net.setInput(blobFromImage(...))net.forward() 完成推理,并输出如下 HED 检测结果(左:输入图像,右:HED 边缘检测结果):

HED 模型边缘检测输出(右图)

6. 最佳实践与常见问题

  1. 注册时机:所有 CV_DNN_REGISTER_LAYER_CLASS / LayerFactory::registerLayer / cv.dnn_registerLayer 必须发生在 readNet* 系列调用之前;运行时注册的自定义层建议用完后 unregisterLayer,避免类型名被后续其他模型误用。
  2. 键名来源:C++ 的注册键以异常信息 Can't create layer "..." of type "MyType" 中的类型名为准;ONNX 自定义域算子额外加 <domain>. 前缀;Python 中替换内置层时使用内置层类型名(如 'Crop')。
  3. 权重与常量:可训练权重导入后位于 cv::dnn::Layer::blobs;TensorFlow 的 Const 输入同样落入 blobs(如 resize 的目标尺寸),需在构造函数中解析。
  4. 内存与形状getMemoryShapes 只需输出形状信息,internals 用于申请中间缓冲;forward 不得依赖上一次调用残留的输出/中间数据。
  5. 固定输入规格:运行时改变输入的高、宽或 batch size 会触发全部内部内存重分配,尽量以固定尺寸部署。
  6. FP16 后端:示例层在 forward 中对 CV_16S 输入调用 forward_fallback,这是针对 DNN_TARGET_OPENCL_FP16 场景的标准兜底写法,推荐在自定义层中保留。

参考文件索引

内容 路径
本教程原文 doc/tutorials/dnn/dnn_custom_layers/dnn_custom_layers.md
C++ 自定义层示例(Interp / ResizeBilinear / MyLayer) samples/dnn/custom_layers.hpp
ONNX 自定义算子可运行示例 samples/dnn/custom_layer_onnx.cpp
Python 自定义 Crop 层 samples/dnn/custom_layer.py
HED 边缘检测完整脚本 samples/dnn/edge_detection.py
注册宏与 LayerFactory 定义 modules/dnn/include/opencv2/dnn/layer.details.hpp
内置层注册入口 modules/dnn/src/init.cpp
Python 绑定 dnn_registerLayer modules/python/src2/cv2.cpp
登录后查看全文
热门项目推荐
相关项目推荐