文章目录
- 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中,添加AllPassLayer层较简单:
- 添加AllPassLayer类,从Layer类(或相关类)继承
- 构造函数AllPassLayer(const cv::dnn::LayerParams ¶ms), 并对父类初始化
- 实现create函数,必须static声明,返回形式固定
- 实现forward函数,对输入进行处理,并输出
- 在主程序代码中,调用宏函数注册该层
上述流程中的步骤要求,可以根据源码进行分析。当前测试具体代码如下:
#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");
}
运行结果
2.2、实现MyConvLayer
在上节中实现AllPassLayer层,是没有需要训练学习得到的数据。这里模仿卷积层,添加一个MyConvLayer层,假设有训练得到的学习数据:卷积权重矩阵weights和偏差矩阵bias。
(若在caffe中实现了该层,且训练后,那么该层是有真实存在的学习得到的权重和偏差矩阵)
在opencv dnn模块 示例(11) 灰度图彩色化 colorization 博客中,加载的模型中没有class8_ab、conv8_313_rh层的训练得到参数,需要额外的对这两层进行设置。
同样给出lenet.prototxt的修改部分截图
这里的 三个参数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");
}
结果如下
注意:
函数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的代码如下
这里的新的层Upsample的参数类型UpsampleParameter有可选的5个uint32类型、2个可选的bool类型,这里使用的参数 只有一个(也可能7个全有)。我们可以在opencv_caffe中找一个满足这数据类型个数(1个或小于等于7个)的已有的层。例如卷积层参数ConvolutionParameter,参数足够了:
这里我们可以如下修改prototxt文件,随便选一个uint32类型替换,就可以正常读取参数。
若有多个参数,同理。若有默认参数,需要在层的实现中额外的处理下。
方案四:直接使用 params.get(“name”),最简单,推荐
实现层后,直接加载模型时,若参数是required,也就是在prototxt中只有一个情况下才能正常。如果有多个值,可能直接读取会出现缺少、报错等问题。例如bn层
读取模型时会出现vector读取out of range错误。这里的解决方法,可以对prototxt的层参数进行如下修改即可 (该层的参数其实在inference中并没有用到,去掉参数也行。就算有用,只要给不同参数名,能够在层的构造函数中通过cv::dnn::LayerParams ¶ms中读取到就可以。这样就完全不需要去修改proto文件)。这里因为没有proto实现,读取的参数默认是string类型,也可以直接使用目标类型进行读取。
读取代码
4、实际项目 ENet
使用ENet官方的caffe分支项目,训练得到的模型不能被opencv加载推理,自定义了2个层,并略微修改protxt文件。
参考github链接:
https://github.com/wanggao1990/opencv_dnn_application/tree/master/caffe-app/caffe-enet