文章目录

  • 1、回顾caffe添加自定义层
  • 2、opencv中添加自定层
  • 2.1、实现AllPassLayer
  • 2.2、实现MyConvLayer
  • 3、加载第三方模型
  • 3.1、opencv中caffe源代码修改
  • 3.2、在项目代码中实现新增的层
  • (1)不需要从prototxt中读取对应层参数
  • (2)需要从prototxt中读取对应层参数
  • 4、实际项目 ENet



本文先回顾caffe中添加层AllPassLayer的流程,再在opencv4.0.0中实现新增AllPassLayer层的代码, 并最后实现一个自定义卷积层MyConvLayer并设置该层训练学习得到的数据。


这里的网络模型使用MNIST分类的lenet网络。

最后,opencv加载其第三方实现的模型,实现了类但仍然不能执行的几种解决方案。

1、回顾caffe添加自定义层

在之前的博客Caffe添加自定义的层中,演示了在caffe框架中添加用户自定义层,AllPassLayer,使得该层输入和输出相同。大致步骤如下:
1、新增层的实现
同其他的Layer类一样,分成声明和实现两个部分,对应放在.hpp和.cpp文件中,如果有cuda实现,还应有.cu文件。其中.hpp头文件放在/caffe-windows/include/caffe/layers/文件夹下,而 .cpp 和 .cu 放入/caffe-windows/src/caffe/layers下。
2、在类外进行注册
INSTANTIATE_CLASS(AllPassLayer)
REGISTER_LAYER_CLASS(AllPass)
3、修改caffe.proto
当新增的层带有参数时,在message LayerParameter { …} 中添加类的参数声明,并编写参数列表 message AllPassParameter { } 。
4、重新编译caffe
编译caffe库,再运行自己的可执行程序。

2、opencv中添加自定层

以两个例子作为说明,一种带学习参数的AllPassLayer层,一种带学习参数的自定义卷积MyConvLayer层。

2.1、实现AllPassLayer

这里在opencv中实现AllPassLayer层,没有需要训练学习得到的数据。仅有一个层的参数key,固定值12.88。

首先给出lenet.prototxt的修改部分截图

opencv自定义colormap opencv自定义层_opencv


在opencv中,添加AllPassLayer层较简单:

  1. 添加AllPassLayer类,从Layer类(或相关类)继承
  2. 构造函数AllPassLayer(const cv::dnn::LayerParams &params), 并对父类初始化
  3. 实现create函数,必须static声明,返回形式固定
  4. 实现forward函数,对输入进行处理,并输出
  5. 在主程序代码中,调用宏函数注册该层

上述流程中的步骤要求,可以根据源码进行分析。当前测试具体代码如下:

#include "opencv2/imgcodecs.hpp"
#include "opencv2/dnn.hpp"
#include "opencv2/highgui.hpp"
#include "opencv2/imgproc.hpp"

#include <iostream>
#include <string>

class AllPassLayer : public cv::dnn::Layer
{
public:
	// 必须有, 参数传递给父类,函数体中根据情况提取自己需要的数据
	AllPassLayer(const cv::dnn::LayerParams ¶ms) : cv::dnn::Layer(params)
	{
		key = params.get<int>("key");
		std::cout << params.type << " params:\n" << "key: " << key << std::endl;
	}
	
	// 必须有,且只能是static
	// return时的new对象就是当前层的类名,固定的形式
	static cv::Ptr<cv::dnn::Layer> create(cv::dnn::LayerParams& params)
	{
		return cv::Ptr<cv::dnn::Layer>(new AllPassLayer(params));
	}

	// 必须有,forward对输入进行处理,计算结果输出
	virtual void forward(cv::InputArrayOfArrays inputs_arr,
		cv::OutputArrayOfArrays outputs_arr,
		cv::OutputArrayOfArrays internals_arr) CV_OVERRIDE
	{
		std::vector<cv::Mat> inputs, outputs;
		inputs_arr.getMatVector(inputs);
		outputs_arr.getMatVector(outputs);

		cv::Mat& inp = inputs[0];
		cv::Mat& out = outputs[0];

		cv::Mat in(inp.size[2], inp.size[3], inp.type(), inp.data);
		cv::Mat ou(out.size[2], out.size[3], out.type(), out.data);

		in.copyTo(ou);
	
		// 演示需要
		cv::Mat outImg;
		ou.convertTo(outImg, CV_8UC1);
		cv::resize(outImg, outImg, outImg.size() * 8);
		cv::imshow("ap output", outImg.clone());
		cv::waitKey(1);
	}

private:
	int key;
};

#include <opencv2/dnn/layer.details.hpp>  // register layer class

int main()
{
	CV_DNN_REGISTER_LAYER_CLASS(AllPass, AllPassLayer); // !!! 必须调用

	std::string imgPath =  R"(E:\DeepLearning\caffe-windows\data\mnist\windows\3.bmp)";
	std::string modelCfg = R"(E:\DeepLearning\caffe-windows\data\mnist\windows\lenet.ap.prototxt)";
	std::string modelBin = R"(E:\DeepLearning\caffe-windows\data\mnist\windows\snapshot_lenet\_iter_10000.caffemodel)";

	// 加载模型
	cv::dnn::Net net = cv::dnn::readNet(modelCfg, modelBin);
	if (net.empty()) {
		std::cout << "net empty" << std::endl;
	}

	// 读取图片,转换成blob
	cv::Mat img = cv::imread(imgPath, 0);  // 要求是 灰度图
	cv::Mat inBlob = cv::dnn::blobFromImage(img, 1, cv::Size{ 28,28 });

	// 设置输入,并推理
	net.setInput(inBlob);
	cv::Mat prop = net.forward();

	// 处理结果
	double minVal, maxVal;
	cv::Point maxLoc;
	cv::minMaxLoc(prop, &minVal, &maxVal, 0, &maxLoc);
	std::cout << cv::format("class id = %d, prob = %f\n", maxLoc.x, maxVal);

	system("pause");
}

运行结果

opencv自定义colormap opencv自定义层_opencv_02

2.2、实现MyConvLayer

在上节中实现AllPassLayer层,是没有需要训练学习得到的数据。这里模仿卷积层,添加一个MyConvLayer层,假设有训练得到的学习数据:卷积权重矩阵weights和偏差矩阵bias。
(若在caffe中实现了该层,且训练后,那么该层是有真实存在的学习得到的权重和偏差矩阵)
在opencv dnn模块 示例(11) 灰度图彩色化 colorization 博客中,加载的模型中没有class8_ab、conv8_313_rh层的训练得到参数,需要额外的对这两层进行设置。

同样给出lenet.prototxt的修改部分截图

opencv自定义colormap opencv自定义层_opencv_03


这里的 三个参数num_output、kernel_size、stride都是1,对于输入图像 [1*1*28*28], 进行卷积之后的输出, 仍然为 [1*1*28*28],计算原理百度。

相对于AllPassLayer层添加,这里的MyConvLayer需要额外的一个函数,重写virtual bool getMemoryShapes(…)函数。若不重写,默认输入经过该层后,输出的shape同输入的shape。这里是模拟添加卷积,多数情况下输出shape是有改变的(尽管示例中3个参数全为1,输出的shape无变化),因此我们这里对输出的尺寸进行计算。

具体代码如下

#include "opencv2/imgcodecs.hpp"
#include "opencv2/dnn.hpp"
#include "opencv2/imgproc.hpp"
#include "opencv2/highgui.hpp"

#include <iostream>
#include <string>

class MyConvLayer : public cv::dnn::Layer
{
public:
	MyConvLayer(const cv::dnn::LayerParams ¶ms) : cv::dnn::Layer(params)
	{
		c = params.get<int>("num_output");
		k = params.get<int>("kernel_size");
		s = params.get<int>("stride");

		std::cout << params.type << " params:\n" << params << std::endl;
	}

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

	// 不重写该方法时,默认输出和输入shape相同,  
	// 这里是卷积,根据输入参数 c,k,s 计算输出的shape
	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
	{
		CV_UNUSED(requiredOutputs); CV_UNUSED(internals);

		// 无padding p=0; 
		// 这里参数 k=1,s=1, 保证输入和输出一样。    outShape[1,1,28,28]
		std::vector<int> outShape(4);	// 可以指定为固定值,调试查看forward中的out值的shape
		outShape[0] = inputs[0][0];
		outShape[1] = c;
		outShape[2] = (inputs[0][2] - k) / s + 1;   //  Hout = (Hin - k + 2*p) / s + 1
		outShape[3] = (inputs[0][3] - k) / s + 1;
		outputs.assign(1, outShape);
		return false;
	}

	// 可选, 根据层的 输入、输出、参数blobs 设置内部需要的参数
	// 多数情况下,代码先通过构造函数获取层的参数,
	// 加上在getMemoryShapes中通过输入尺寸,能够计算输出尺寸和内部需要的数据集尺寸
	// 但是 默认是getMemoryShapes 是const限定,要么重新实现一个,要么设置参数为 mutable
	// 因此,建议 通过输入、参数、甚至包括输出,计算内部需要数据,建议在这里实现
	virtual void finalize(InputArrayOfArrays inputs_arr, OutputArrayOfArrays outputs_arr)
	{
		std::vector<cv::Mat> inputs, outputs;
		inputs_arr.getMatVector(inputs);
		outputs_arr.getMatVector(outputs);

		auto os_shape = [](cv::MatSize& ms){
			std::stringstream out;
			out << " [";
			for ( int i = 0; i < ms.dims() - 1; i++) 
				out << ms[i] << ", ";
			out << ms[ms.dims() - 1] << "]";
			return out.str();
		};
		
		std::cout << "\nLayer " << name;
		std::cout << "\n  input shapes  ";
		for (auto& input : inputs) std::cout << os_shape(input.size);

		std::cout << "\n  output shapes  ";
		for (auto& output : outputs) std::cout << os_shape(output.size);

		std::cout << "\n  weights shapes  ";
		for (auto& blob : blobs) std::cout << os_shape(blob.size);
	}

    // 从cv::dnn::Layer继承实现自定义的层,必须重写
	virtual void forward(cv::InputArrayOfArrays inputs_arr,
		cv::OutputArrayOfArrays outputs_arr,
		cv::OutputArrayOfArrays internals_arr) CV_OVERRIDE
	{
		std::vector<cv::Mat> inputs, outputs, internals;
		inputs_arr.getMatVector(inputs);
		outputs_arr.getMatVector(outputs);

		cv::Mat& inp = inputs[0];   // 1*1*28*28
		cv::Mat& out = outputs[0];  // 1*1*28*28,  由getMemoryShapes()决定

		CV_Assert(blobs.size() == 2);   // 保证已经有 weight,bias
		cv::Mat& weight = this->blobs[0];   // 1*1*1*1    // 4D
		cv::Mat& bias = this->blobs[1];		// 1*1		// 2D

		//1通道,1*1卷积,实际可以简化矩阵运算  ouput = w*input + b

		// 为后续需要,写成并行, 需要c++11支持
		cv::parallel_for_(cv::Range(0, c), [&](const cv::Range& range) {
			for (int r = range.start; r < range.end; r++) {
				cv::Mat blockIn(inp.size[2], inp.size[3], CV_32F, inp.ptr<float>(0, r));
				cv::Mat blockW(weight.size[2], weight.size[3], CV_32F, weight.ptr<float>(0, r));
				float blockB = *bias.ptr<float>(0, r);
				cv::Mat blockOut(out.size[2], out.size[3], CV_32F, out.ptr<float>(0, r));

				// output = w*input + b
				cv::filter2D(blockIn, blockOut, CV_32F, blockW);
				blockOut += blockB;
			}
		});

		// 演示需要
		cv::Mat ou(out.size[2], out.size[3], out.type(), out.data);
		cv::Mat outImg;
		ou.convertTo(outImg, CV_8UC1);
		cv::resize(outImg, outImg, outImg.size() * 8);
		cv::imshow("ap output", outImg.clone());
		cv::waitKey(1);
	}

private:
	int c, k, s;
};

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

int main()
{
	CV_DNN_REGISTER_LAYER_CLASS(MyConv, MyConvLayer);   // 注册自定义卷积层, 1x1卷积, 并且 参数 w=1,b=0,  保证输入等于输出

	std::string imgPath = R"(E:\DeepLearning\caffe-windows\data\mnist\windows\3.bmp)";
	std::string modelCfg = R"(E:\DeepLearning\caffe-windows\data\mnist\windows\lenet.mc.prototxt)";
	std::string modelBin = R"(E:\DeepLearning\caffe-windows\data\mnist\windows\snapshot_lenet\_iter_10000.caffemodel)";

	// 加载模型
	cv::dnn::Net net = cv::dnn::readNet(modelCfg, modelBin);
	if (net.empty()) {
		std::cout << "net empty" << std::endl;
	}

	// 获取自定义卷积层
	int apLayerId = net.getLayerId("mc");
	auto layer = net.getLayer(apLayerId);

	 对自定义卷积层添加卷积核权重和偏差数据(需要同forward的顺序保持一致)
	// 额外设置网络学到的参数(没有利用新层进行训练,caffemodel中没有对应w和b的参数数据)
	// 1x1的卷积核,1个;  偏置bias为1个 ;  保证 输入输出都为 1*1*28*28
	int wSZ[] = { 1,1,1,1 };	// n*c*h*w  权重为4D
	int bSZ[] = { 1,1 };		// n*c      偏置项为2D
	layer->blobs.emplace_back(cv::Mat(4, wSZ, CV_32FC1, cv::Scalar(1)));  //  blob[0] 卷积权重weights
	layer->blobs.emplace_back(cv::Mat(2, bSZ, CV_32FC1, cv::Scalar(0)));  //  blob[1] 偏置bias

	// 读取图片,转换成blob
	cv::Mat img = cv::imread(imgPath, 0);  // lenet 要求灰度图
	cv::Mat inBlob = cv::dnn::blobFromImage(img, 1, cv::Size{ 28,28 });
	net.setInput(inBlob);

	// 网络推理
	cv::Mat prop = net.forward();

	// 处理结果
	double minVal, maxVal;
	cv::Point maxLoc;
	cv::minMaxLoc(prop, &minVal, &maxVal, 0, &maxLoc);
	std::cout << cv::format("class id = %d, prob = %f\n", maxLoc.x, maxVal);

	system("pause");
}

结果如下

opencv自定义colormap opencv自定义层_dnn_04

注意:
函数virtual void finalize(InputArrayOfArrays inputs_arr, OutputArrayOfArrays outputs_arr) 的重写是可选的, 作用是根据层的 输入、输出、参数blobs 设置内部需要的参数。
多数情况下,代码先通过执行构造函数获取层的参数, 再执行getMemoryShapes获取层的输入尺寸,能够计算输出尺寸和内部需要的数据集尺寸 。
但是getMemoryShapes 是const限定的,要么重新实现一个(无const限定),要么将该层需要修改的参数为设置为 mutable。
因此,建议通过输入、参数、甚至包括输出,重写finalize()计算内部需要数据。

另外: readNet()执行内容有:解析网络配置文件,net中添加每一个层实例,根据层的输入和参数计算输出(执行getMemoryShapes),分配内存,计算中间变量(若有,执行finalize)。 forward()从前至后一次对每一层(指定层或最后一层之前的层)执行forwardLayer,最后返回指定层或最后一层输出。

3、加载第三方模型

以caffe为例,第三方的模型提供了prototxt和caffemodel,直接使用opencv接口,肯定会出错。
不同情况解决方式有多种。

3.1、opencv中caffe源代码修改

按照步骤做一下工作,新增层的实现,在类外进行注册,修改caffe.proto,重新编译opencv。

这种方式可以直接将他人的源代码中各部分代码移植过来,仅多了重新编译。

3.2、在项目代码中实现新增的层

这种情况下,可以直接利用opencv的代码去实现层的代码,相对简单一点。但是,重点是 这个层有无参数需要从prototxt中读取,有无对应不同的方式。

(1)不需要从prototxt中读取对应层参数

这里不需要读取,只实现层即可。

(2)需要从prototxt中读取对应层参数

方案一:修改opencv_caffe.proto,重新编译opencv
这种方式已和3.1相同。

方案二:修改opencv_caffe.proto,使用protoc.exe重新生成.pb.h和.pb.cc文件
添加opencv_caffe.pb.h、opencv_caffe.pb.cc以及caffe_importer.cpp和相关文件(protobuffer的头文件、库等)到项目中,并作部分调整,例如caffe_importer.cpp修改为caffe_importer.hpp,移除不需要的引用。
这种条件下,参数可以正常读取,层的运行也正常。

方案三:替换protxt文件中新增层的参数类型名、包含参数名

例如 ,新增的层 在.proto和 .prototxt的代码如下

opencv自定义colormap opencv自定义层_自定义层_05


这里的新的层Upsample的参数类型UpsampleParameter有可选的5个uint32类型、2个可选的bool类型,这里使用的参数 只有一个(也可能7个全有)。我们可以在opencv_caffe中找一个满足这数据类型个数(1个或小于等于7个)的已有的层。例如卷积层参数ConvolutionParameter,参数足够了:

opencv自定义colormap opencv自定义层_dnn_06


这里我们可以如下修改prototxt文件,随便选一个uint32类型替换,就可以正常读取参数。

若有多个参数,同理。若有默认参数,需要在层的实现中额外的处理下。

opencv自定义colormap opencv自定义层_opencv_07


方案四:直接使用 params.get(“name”),最简单,推荐

实现层后,直接加载模型时,若参数是required,也就是在prototxt中只有一个情况下才能正常。如果有多个值,可能直接读取会出现缺少、报错等问题。例如bn层

opencv自定义colormap opencv自定义层_dnn_08


读取模型时会出现vector读取out of range错误。这里的解决方法,可以对prototxt的层参数进行如下修改即可 (该层的参数其实在inference中并没有用到,去掉参数也行。就算有用,只要给不同参数名,能够在层的构造函数中通过cv::dnn::LayerParams &params中读取到就可以。这样就完全不需要去修改proto文件)。这里因为没有proto实现,读取的参数默认是string类型,也可以直接使用目标类型进行读取。

opencv自定义colormap opencv自定义层_dnn_09

opencv自定义colormap opencv自定义层_dnn_10


读取代码

opencv自定义colormap opencv自定义层_opencv_11

4、实际项目 ENet

使用ENet官方的caffe分支项目,训练得到的模型不能被opencv加载推理,自定义了2个层,并略微修改protxt文件。
参考github链接:
https://github.com/wanggao1990/opencv_dnn_application/tree/master/caffe-app/caffe-enet