从零开始山寨Caffe·拾:IO系统(三)
数据变形
IO(二)中,我们已经将原始数据缓冲至Datum,Datum又存入了生产者缓冲区,不过,这离消费,还早得很呢。
在消费(使用)之前,最重要的一步,就是数据变形。
ImageNet
ImageNet提供的数据相当Raw,不仅图像尺寸不一,ROI焦点内容比例也不一,如图:
[Krizhevsky12]给出了CNN打ImageNet的基本预处理,非常经典的" Random 256-224 Crop",即:
首先,对图片进行统一的缩放,无视宽高比,统一缩放成256*256(可利用OpenCV)
(注:保留宽高比是没有意义的,CNN的滑动卷积本身就会破坏宽高比,见Faster-RCNN的RPN设计原理)
预先计算好256*256图像的均值,在硬盘上存储为均值文件。之后,分为训练阶段和测试阶段。
【训练阶段】:
对256*256的图片,只选择224*224的crop区域,目的是做Data Augmentation。
crop方式很特殊,采用的是随机crop。由于256-224=32,宽高轴上各有32单元的平移空间。
于是在训练时,每次Rand(0,32),宽高轴一共就有32*32种crop结果,达到了数据增幅效果。
同时,还要对crop结果,做一次镜像,这样就有2*32*32=2048倍的增幅数据了。
【测试阶段】:
对256*256的图片,将224*224的crop区域分别定位在4角和图片中心,加上镜像,共计10种结果。
累加Softmax的prob,做平均,得到最终prob,最后再作出prediction。
均值标准化
作为经典的通用数据预处理手段,均值标准化相当廉价,效果不俗。
默认有俩种均值标准化:逐像素(精)、逐通道(糙)。
Caffe中对逐像素均值数据进行的是外挂存储,和图像数据是分开的,这样的存储相当灵活。
代价就是,对每一张图要进行减均值操作,在GPU模式中,CPU的这点计算量其实没什么。
对于逐通道均值,直接在proto文本中,作为参数指定。
数值缩放
[Krizhevsky12] 中,使用更灵活的Gaussian初始化,网络首层参数初始化的标准差缩小100倍(0.0001)
以此免除了传统意义上的数值缩放。
如果你需要使用Xavier初始化,仍然需要校正输入范围至[-1,1]。
[0,256]范围需要乘以1/256=0.00390625的缩放因子。
[-128,128]范围(做了均值标准化)需要乘以1/128=0.0078125的缩放因子。
镜像
可以OpenCV做。因为镜像不涉及插值,也可以人工逆转坐标完成。
数据结构
(注:Transformer中含有大量OpenCV函数的使用,以下将精简掉所有OpenCV功能,请读者按需自行补充)
在proto文件中,补上TransformationParameter 。
message TransformationParameter{ optional float scale=1 [default=1.0]; optional bool mirror=2 [default=false]; optional uint32 crop_size=3 [default=0]; optional string mean_file=4; repeated float mean_value=5; optional bool force_color=6 [default=false]; optional bool force_gray=7 [default=false]; }
在LayerParameter,补上:
optional TransformationParameter transform_param=XX;
Transformer将作为DataLayer的成员变量,接受LayerParameter传进来的transform_param进行构造。
建立data_transformer.hpp
template <typename Dtype> class DataTransformer { public: DataTransformer(const TransformationParameter& param, Phase phase); vector<int> inferBlobShape(const Datum& datum); void transform(const Datum& datum, Blob<Dtype>* shadow_blob); void transform(const Datum& datum, Dtype* shadow_data); void initRand(); ~DataTransformer() {} int rand(int n); private: TransformationParameter param; Phase phase; Blob<Dtype> mean_blob; vector<Dtype> mean_vals; boost::shared_ptr<Dragon::RNG> ptr_rng; };
inferBlobShape、transfrom都是外调成员函数,将被DataLayer使用。
分别用于根据数据推测DataLayer的Blob大小、以及对数据变形。
initRand将构造梅森发生器ptr_rng,rand用于Random-Crop。
根据均值标准化的不同,mean_blob存储逐像素均值,mean_val则是简单的逐通道均值。
Protocol Buffer的文件IO封装
反序列化以二进制存储的均值文件,需要操作Protocol Buffer的底层文件系统API,为了便于调用,做一个Wrapper。
建立io.hpp。
#include <fcntl.h> #include <unistd.h> #include <google/protobuf/message.h> #include <google/protobuf/io/coded_stream.h> #include <google/protobuf/io/zero_copy_stream_impl.h> #include <google/protobuf/text_format.h> inline bool readProtoFromBinaryFile(const char* filename, Message* proto){ // get OS kernel‘s file descriptor(fd) // successful range: [0,OPEN_MAX] // replace open(filename, O_RDONLY) as open(filename, O_RDONLY | O_BINARY) int fd = open(filename, O_RDONLY | O_BINARY); CHECK_NE(fd, -1) << "File not found: " << filename; ZeroCopyInputStream *raw_input = new FileInputStream(fd); CodedInputStream *coded_input = new CodedInputStream(raw_input); coded_input->SetTotalBytesLimit(INT_MAX, 536870912); // 0..512M..2G bool success = proto->ParseFromCodedStream(coded_input); delete raw_input; delete coded_input; close(fd); return success; }
值得在意的是OS提供的API函数open,返回的是fd(file descriptor),这和OS的文件系统有关。
Linux的open函数,默认是以O_RDONLY打开的,而Windows则不是。
因此,移植Linux版Caffe的第一步就是追加O_RDONLY这个Flag。
ZeroCopyInputStream相比于PB提供的InputStream,速度要更快。
CodedInputStream为了解除二进制的编码,SetTotalBytesLimit两参数分别是文件大小上界和警告阈值(2G/512M)。
最后,将二进制编码数据,反序列化成为Message结构。
实现
建立data_transformer.cpp
template <typename Dtype> DataTransformer<Dtype>::DataTransformer(const TransformationParameter& param, Phase phase): param(param), phase(phase) { // normally, we get mean_value from mean_file if (param.has_mean_file()){ CHECK_EQ(param.mean_value_size(), 0)
<< "System wants to use mean_file but specified mean_value."; const string& mean_file = param.mean_file(); LOG(INFO) << "Loading mean file from: " << mean_file; BlobProto proto; readProtoFromBinaryFileOrDie(mean_file.c_str(), &proto); mean_blob.FromProto(proto); } // using each channel's mean value // mean_value_size() is between 1 and 3 if (param.mean_value_size()>0){ CHECK(param.has_mean_file() == false)
<< "System wants to use mean_value but specified mean_file."; for (int i = 0; i < param.mean_value_size(); i++) mean_vals.push_back(param.mean_value(i)); }
initRand(); }
构造函数中,主要做两件事:
①恢复均值数据,逐像素从文件读,逐通道从指定的proto参数里读。
逐通道参数指定方法:
layer { ......... transform_param { mean_val: 102 mean_val: 107 mean_val: 112 ......... } }
proto的repeated类型,可以通过相同的名字,连续指定。
②初始化梅森发生器。
均值数据的序列化,是放在BlobProto里的,反序列会成为BlobProto。
关于如何存储均值,见:https://github.com/neopenx/Dragon/blob/master/Dragon/compute_mean.cpp
template<typename Dtype> vector<int> DataTransformer<Dtype>::inferBlobShape(const Datum& datum){ const int crop_size = param.crop_size(); const int channels = datum.channels(); const int height = datum.height(); const int width = datum.width(); CHECK_GT(channels, 0); CHECK_GE(height, crop_size); CHECK_GE(width,crop_size); vector<int> shape(4); shape[0] = 1; shape[1] = channels; shape[2] = crop_size ? crop_size : height; shape[3] = crop_size ? crop_size : width; return shape; }
InferBlobShape接受一个Datum,返回推测的shape,用于构建DataLayer中,Flow的Blob。
template<typename Dtype> void DataTransformer<Dtype>::initRand(){ const bool must_rand = (phase == TRAIN && param.crop_size()); if (must_rand){ const unsigned int rng_seed = Dragon::get_random_value(); ptr_rng.reset(new Dragon::RNG(rng_seed)); } }
梅森发生器的构建使用了主进程管理器的梅森发生器提供的一个随机数作为种子。
这步可以省略,使用进程相关的cluster_seedgen也是可以的。
template<typename Dtype> int DataTransformer<Dtype>::rand(int n){ CHECK(ptr_rng); CHECK_GT(n, 0); rng_t* rng = ptr_rng->get_rng(); return (*rng)() % n; }
32位的梅森发生器默认产生一个unsigned int32值,如果需要指定范围,需要做求余操作。
同时,注意Random-Crop不需要负随机值。
template<typename Dtype> void DataTransformer<Dtype>::transform(const Datum& datum, Dtype* shadow_data){ // pixel can be compressed as a string // cause each pixel ranges from 0~255 (a char) const string& data = datum.data(); const int datum_channels = datum.channels(); const int datum_height = datum.height(); const int datum_width = datum.width(); const int crop_size = param.crop_size(); const Dtype scale = param.scale(); const bool must_mirror = param.mirror(); //need rand!!! const bool has_mean_file = param.has_mean_file(); const bool has_uint8 = data.size() > 0; //pixels are compressed as a string const bool has_mean_value = mean_vals.size() > 0; CHECK_GT(datum_channels, 0); CHECK_GE(datum_height, crop_size); CHECK_GE(datum_width, crop_size); Dtype *mean = NULL; if (has_mean_file){ CHECK_EQ(datum_channels, mean_blob.channels()); CHECK_EQ(datum_height, mean_blob.height()); CHECK_EQ(datum_width, mean_blob.width()); mean = mean_blob.mutable_cpu_data(); } if (has_mean_value){ CHECK(mean_vals.size() == 1 || mean_vals.size() == datum_channels) << "Channel's mean value must be provided as a single value or as many as channels."; //replicate if (datum_channels > 1 && mean_vals.size() == 1) for (int i = 0; i < datum_channels - 1; i++) mean_vals.push_back(mean_vals[0]); } int h_off = 0, w_off = 0, height = datum_height, width = datum_width; if (crop_size){ height = crop_size; width = crop_size; // train phase using random croping if (phase == TRAIN){ h_off = rand(datum_height - height + 1); w_off = rand(datum_width - width + 1); } // test phase using expected croping else{ h_off = (datum_height - height) / 2; w_off = (datum_width - width) / 2; } } Dtype element; int top_idx, data_idx; //copy datum values to shadow_data-> batch for (int c = 0; c < datum_channels; c++){ for (int h = 0; h < height; h++){ for (int w = 0; w < width; w++){ data_idx = (c*datum_height + h_off + h)*datum_width + w_off + w; if (must_mirror) top_idx = (c*height + h)*width + (width - 1 - w); //top_left=top_right else top_idx = (c*height + h)*width + w; if (has_uint8){ // char type can not cast to Dtype directly // or will generator mass negative number(facing Cifar10) element=static_cast<Dtype>(static_cast<uint8_t>(data[data_idx])); } else element = datum.float_data(data_idx); //Dtype <- float if (has_mean_file) shadow_data[top_idx] = (element - mean[data_idx])*scale; else if (has_mean_value) shadow_data[top_idx] = (element - mean_vals[c])*scale; else shadow_data[top_idx] = element*scale; } } } }
上面是几种transform的核心操作,还是比较冗繁的。
首先从Datum获得输入数据尺寸,做Random-Crop。
在训练阶段,得到基于原图的两个偏移h_off,w_off。
在测试阶段,默认没有实现[Krizhevsky12]的10个测试区域多重预测,只提供单中心crop区域。
需要根据具体要求,重写这部分代码。比如GoogleNet就扩大到了144个测试区域,具体见[Szegedy14]
接着,逐通道、逐像素(crop之后的宽高):
data_idx由crop位置+偏移位置联合而成,代表原图的像素位置。
top_idx代表的是crop图的位置。
如果需要镜像(反转width轴),在计算top_idx的最后,用(width - 1 - w)替代w。
uint8这里需要特别注意:
string里的字符类型是char,而uint8是unsigned char,需要强制转换。
诸如MNIST、Cifar10这样的数据集,像素单元是以uint8存储的。
8Bit的顶位用于存储符号位,unit8范围是[0,255],int8范围是[-127,127]。
如果不转换,从char(string)中获取的值,顶位将用于符号,显然不能表达我们的像素要求。
最后,均值和缩放可以在一行完成。
template<typename Dtype> void DataTransformer<Dtype>::transform(const Datum& datum, Blob<Dtype>* shadow_blob){ const int num = shadow_blob->num(); const int channels = shadow_blob->channels(); const int height = shadow_blob->height(); const int width = shadow_blob->width(); CHECK_EQ(channels, datum.channels()); CHECK_GE(num, 1); CHECK_LE(height, datum.height()); //allowing crop CHECK_LE(width, datum.width()); Dtype *base_data = shadow_blob->mutable_cpu_data(); transform(datum, base_data); }
这个transform的重载函数是对Blob的封装。(可选)
完整代码
io.hpp
https://github.com/neopenx/Dragon/blob/master/Dragon/include/utils/io.hpp
data_transformer.hpp
https://github.com/neopenx/Dragon/blob/master/Dragon/include/data_transformer.hpp
data_transformer.cpp
https://github.com/neopenx/Dragon/blob/master/Dragon/src/data_transformer.cpp