opencv源码解析之(6):hog源码分析

 一、网上一些参考资料

       在博客目标检测学习_1(用opencv自带hog实现行人检测) 中已经使用了opencv自带的函数detectMultiScale()实现了对行人的检测,当然了,该算法采用的是hog算法,那么hog算法是怎样实现的呢?这一节就来简单分析一下opencv中自带 hog源码。

       网上也有不少网友对opencv中的hog源码进行了分析,很不错,看了很有收获。比如:

    http://blog.csdn.net/raocong2010/article/details/6239431

    该博客对该hog算法中用到的block,cell等概念有一定的图标解释;

    http://blog.csdn.net/pp5576155/article/details/7029699

    该博客是转载的,里面有opencv源码的一些注释,很有帮助。

    http://gz-ricky.blogbus.com/logs/85326280.html

    本博客对hog描述算子长度的计算做了一定介绍。

    http://hi.baidu.com/susongzhi/item/3a3c758d7ff5cbdc5e0ec172

    该博客对hog中快速算法的三线插值将得很详细。

    http://blog.youtueye.com/work/opencv-hog-peopledetector-trainning.html

    这篇博客对hog怎样训练和检测做了一定的讲解。

 

         二、关于源码的一些简单说明

         本文不是讲解hog理论的,所以需要对hog算法有一定了解,这些可以去参考hog提出者的博士论文,写得很详细。

         按照正常流程,hog行人检测分为训练过程和检测过程,训练过程主要是训练得到svm的系数。在opencv源码中直接采用训练好了的svm系数,所以训练过程源码中没有涉及到多少。

   首先还是对hog源码中一些固定参数来个简单说明:

   检测窗口大小为128*64;

   Block大小为16*16;

   Cell大小为8*8;

   Block在检测窗口中上下移动尺寸为8*8;

   1个cell的梯度直方图化成9个bin;

   滑动窗口在检测图片中滑动的尺寸为8*8;

   代码中的一个hog描述子是针对一个检测窗口而言的,所以一个检测窗口共有105=((128-16)/8+1)*((64-16)/8+1)个block;一个block中有4个cell,而一个cell的hog描述子向量的长度为9;所以检测窗口的hog向量长度=3780=105*4*9维。

 

         三、hog训练部分流程的简单理解

         虽然hog源码中很少涉及到训练部分的代码,不过了解下训练过程的流程会对整个检测过程有个整体认识。

         训练过程中正样本大小统一为128*64,即检测窗口的大小;该样本图片可以包含1个或多个行人。对该图片提前的hog特征长度刚好为3780维,每一个特征对应一个正样本标签进行训练。在实际的训练过程中,我们并不是去google上收集或者拍摄刚好128*64大小且有行人的图片,而是收集包含行人的任意图片(当然了,尺寸最好比128*64大),然后手工对这些正样本进行标注,即对有行人的地方画个矩形,其实也就是存了2个顶点的坐标而已,并把这个矩形的信息存储起来;最好自己写一个程序,每读入一张图片,就把矩形区域的内容截取出来并缩放到统一尺寸128*64,这样,对处理过后的该图片进行hog特征提取就可以当做正样本了。

         负样本不需要统一尺寸,只需比128*64大,且图片中不能包含任何行人。实际过程中,由于是负样本,里面没有目标信息,所以不需要人工进行标注。程序中可以对该图片随机进行截取128*64大小的图片,并提取出其hog特征作为负样本。

 

         四、ho行人检测过程

         检测过程中采用的是滑动窗口法,对应本代码中,滑动窗口法的流程如下:

    

 

         由上图可以看出,检测时,会对输入图片进行尺度缩放(一般是缩小),在每一层的图像上采用固定大小的滑动窗口(128*64)滑动,没个滑动窗口都提取出hog特征,送入到svm分类器中,看该窗口中是否有目标。有则存下目标区域来,无则继续滑动。

         检测过程中用到的函数为detectMultiScale(),其参数分配图如下:

 

         五、计算检测窗口中图像的梯度

         计算梯度前如果需要gamma校正的话就先进行gamma校正,所谓的gamma校正就是把原来的每个通道像素值范围从0~255变换到0~15.97(255开根号)。据作者说这样校正过后的图像计算的效果会更好,在计算梯度前不需要进行高斯滤波操作。

         梯度的计算是分别计算水平梯度图和垂直梯度图,然后求幅值和相位。水平梯度卷积算子为:

      

    垂直梯度卷积算子为:

     

         在阅读该源码的时候,要特别注意梯度幅值和角度的存储方式。因为是对一个滑动窗口里的图像进行的,所以梯度幅值和角度按照道理来说应该都是128*64=8192维的向量。但实际过程中这2者都是用的128*64*2=16384维的向量。为什么呢?

         因为这里的梯度和角度都是用到了二线插值的。每一个点的梯度角度可能是0~180度之间的任意值,而程序中将其离散化为9个bin,即每个bin占20度。所以滑动窗口中每个像素点的梯度角度如果要离散化到这9个bin中,则一般它都会有2个相邻的bin(如果恰好位于某个bin的中心,则可认为对该bin的权重为1即可)。从源码中可以看到梯度的幅值是用来计算梯度直方图时权重投票的,所以每个像素点的梯度幅值就分解到了其角度相邻的2个bin了,越近的那个bin得到的权重越大。因此幅度图像用了2个通道,每个通道都是原像素点幅度的一个分量。同理,不难理解,像素点的梯度角度也用了2个通道,每个通道中存储的是它相邻2个bin的bin序号。序号小的放在第一通道。

         二线插值的示意图如下:

    

 

         其中,假设那3条半径为离散化后bin的中心,红色虚线为像素点O(像素点在圆心处)的梯度方向,梯度幅值为A,该梯度方向与最近的相邻bin为bin0,这两者之间的夹角为a.这该像素点O处存储的梯度幅值第1通道为A*(1-a),第2通道为A*a;该像素点O处存储的角度第1通道为0(bin的序号为0),第2通道为1(bin的序号为1)。

         另外在计算图像的梯度图和相位图时,如果该图像时3通道的,则3通道分别取梯度值,并且取梯度最大的那个通道的值为该点的梯度幅值。

 

         六、HOG缓存结构体

         HOG缓存思想是该程序作者加快hog算法速度采用的一种内存优化技术。由于我们对每幅输入图片要进行4层扫描,分别为图像金字塔层,每层中滑动窗口,每个滑动窗口中滑动的block,每个block中的cell,其实还有每个cell中的像素点;有这么多层,每一层又是一个二维的,所以速度非常慢。作者的采用的思想是HOG缓存,即把计算得到的每个滑动窗口的数据(其实最终是每个block的hog描述子向量)都存在内存查找表中,由于滑动窗口在滑动时,很多个block都会重叠,因此重叠处计算过的block信息就可以直接从查找表中读取,这样就节省了很多时间。

         在这个HOG存储结构体中,会计算滑动窗口内的hog描述子,而这又涉及到滑动窗口,block,cell直接的关系,其之间的关系可以参考下面示意图:

     

         外面最大的为待检测的图片,对待检测的图片需要用滑动窗口进行滑动来判断窗口中是否有目标,每个滑动窗口中又有很多个重叠移动的block,每个block中还有不重叠的cell。其实该程序的作者又将每个block中的像素点对cell的贡献不同,有将每个cell分成了4个区域,即图中蓝色虚线最小的框。

         那么block中不同的像素点对它的cell(默认参数为1个block有4个cell)的影响是怎样的呢?请看下面示意图。

            

         如果所示,黑色框代表1个block,红实线隔开的为4个cell,每个cell用绿色虚线隔开的我们称之为4个区域,所以该block中共有16个区域,分别为A、B、C、…、O、P。

         程序中将这16个区域分为4组:

         第1组:A、D、M、P;该组内的像素点计算梯度方向直方图时只对其所在的cell有贡献。

         第2组:B、C、N、O;该组内的像素点计算梯度直方图时对其所在的左右cell有贡献。

         第3组:E、I、H、L;该组内的像素点计算梯度直方图时对其所在的上下cell有贡献。

         第4组:F、G、J、K;该组内的像素点对其上下左右的cell计算梯度直方图时都有贡献。

         那到底是怎么对cell贡献的呢?举个例子来说,E区域内的像素点对cell0和cell2有贡献。本来1个block对滑动窗口贡献的向量维数为36维,即每个cell贡献9维,其顺序分别为cell0,cell1,cell2,cell3.而E区域内的像素由于同时对cell0和cell2有贡献,所以在计算E区域内的像素梯度投票时,不仅要投向它本来的cell0,还要投向下面的cell2,即投向cell0和cell2有一个权重,该权重与该像素点所在位置与cell0,cell2中心位置的距离有关。具体的关系可以去查看源码。

         该结构体变量内存分配图如下,可以增强读代码的直观性:

    

 

           在读该部分源码时,需要特别注意以下几个地方:

    1)         结构体BlockData中有2个变量。1个BlockData结构体是对应的一个block数据。histOfs和imgOffset.其中histOfs表示为该block对整个滑动窗口内hog描述算子的贡献那部分向量的起始位置;imgOffset为该block在滑动窗口图片中的坐标(当然是指左上角坐标)。

    2)         结构体PixData中有5个变量,1个PixData结构体是对应的block中1个像素点的数据。其中gradOfs表示该点的梯度幅度在滑动窗口图片梯度幅度图中的位置坐标;qangleOfs表示该点的梯度角度在滑动窗口图片梯度角度图中的位置坐标;histOfs[]表示该像素点对1个或2个或4个cell贡献的hog描述子向量的起始位置坐标(比较抽象,需要看源码才懂)。histWeight[]表示该像素点对1个或2个或4个cell贡献的权重。gradWeight表示该点本身由于处在block中位置的不同因而对梯度直方图贡献也不同,其权值按照二维高斯分布(以block中心为二维高斯的中心)来决定。

    3)         程序中的count1,cout2,cout4分别表示该block中对1个cell、2个cell、4个cell有贡献的像素点的个数。

 

    七、其他一些函数

    该程序中还有一些其它的函数。

    getblock()表示的是给定block在滑动窗口的位置以及图片的hog缓存指针,来获得本次block中计算hog特征所需要的信息。

    normalizeBlockHistogram()指对block获取到的hog部分描述子进行归一化,其实该归一化有2层,具体看代码。

    windowsInImage()实现的功能是给定测试图片和滑动窗口移动的大小,来获得该层中水平和垂直方向上需要滑动多少个滑动窗口。

    getWindow()值获得一个滑动窗口矩形。

    compute()是实际上计算hog描述子的函数,在测试和训练阶段都能用到。

    detect()是检测目标是用到的函数,在detectMultiScale()函数内部被调用。

 

         八、关于HOG的初始化

         Hog初始化可以采用直接赋初值;也直接从文件节点中读取(有相应的格式,好像采用的是xml文件格式);当然我们可以读取初始值,也可以在程序中设置hog算子的初始值并写入文件,这些工作可以采用源码中的read,write,load,save等函数来完成。

 

         九、hog源码的注释

         在读源码时,由于里面用到了intel的ipp库,优化了算法的速度,所以在程序中遇到#ifdef HAVE_IPP后面的代码时,可以直接跳过不读,直接读#else后面的代码,这并不影响对原hog算法的理解。

         首先来看看hog源码中用到的头文件目录图,如下:

    

 

    下面是我对hog源码的一些注释,由于本人接触c++比较少,可能有些c++的语法常识也给注释起来了,还望大家能理解。另外程序中还有一些细节没有读懂,或者说是注释错了的,大家可以一起来讨论下,很多细节要在源码中才能看懂。

/*M///
 //
 //  IMPORTANT: READ BEFORE DOWNLOADING, COPYING, INSTALLING OR USING.
 //
 //  By downloading, copying, installing or using the software you agree to this license.
 //  If you do not agree to this license, do not download, install,
 //  copy or use the software.
 //
 //
 //                           License Agreement
 //                For Open Source Computer Vision Library
 //
 // Copyright (C) 2000-2008, Intel Corporation, all rights reserved.
 // Copyright (C) 2009, Willow Garage Inc., all rights reserved.
 // Third party copyrights are property of their respective owners.
 //
 // Redistribution and use in source and binary forms, with or without modification,
 // are permitted provided that the following conditions are met:
 //
 //   * Redistribution's of source code must retain the above copyright notice,
 //     this list of conditions and the following disclaimer.
 //
 //   * Redistribution's in binary form must reproduce the above copyright notice,
 //     this list of conditions and the following disclaimer in the documentation
 //     and/or other materials provided with the distribution.
 //
 //   * The name of the copyright holders may not be used to endorse or promote products
 //     derived from this software without specific prior written permission.
 //
 // This software is provided by the copyright holders and contributors "as is" and
 // any express or implied warranties, including, but not limited to, the implied
 // warranties of merchantability and fitness for a particular purpose are disclaimed.
 // In no event shall the Intel Corporation or contributors be liable for any direct,
 // indirect, incidental, special, exemplary, or consequential damages
 // (including, but not limited to, procurement of substitute goods or services;
 // loss of use, data, or profits; or business interruption) however caused
 // and on any theory of liability, whether in contract, strict liability,
 // or tort (including negligence or otherwise) arising in any way out of
 // the use of this software, even if advised of the possibility of such damage.
 //
 //M*/
 
 #include "precomp.hpp"
 #include <iterator>
 #ifdef HAVE_IPP
 #include "ipp.h"
 #endif
 /****************************************************************************************\
       The code below is implementation of HOG (Histogram-of-Oriented Gradients)
       descriptor and object detection, introduced by Navneet Dalal and Bill Triggs.
 
       The computed feature vectors are compatible with the
       INRIA Object Detection and Localization Toolkit
       (http://pascal.inrialpes.fr/soft/olt/)
 \****************************************************************************************/
 
 namespace cv
 {
 
 size_t HOGDescriptor::getDescriptorSize() const
 {
     //下面2个语句是保证block中有整数个cell;保证block在窗口中能移动整数次
     CV_Assert(blockSize.width % cellSize.width == 0 &&
         blockSize.height % cellSize.height == 0);
     CV_Assert((winSize.width - blockSize.width) % blockStride.width == 0 &&
         (winSize.height - blockSize.height) % blockStride.height == 0 );
     //返回的nbins是每个窗口中检测到的hog向量的维数
     return (size_t)nbins*
         (blockSize.width/cellSize.width)*
         (blockSize.height/cellSize.height)*
         ((winSize.width - blockSize.width)/blockStride.width + 1)*
         ((winSize.height - blockSize.height)/blockStride.height + 1);
 }
 
 //winSigma到底是什么作用呢?
 double HOGDescriptor::getWinSigma() const
 {
     return winSigma >= 0 ? winSigma : (blockSize.width + blockSize.height)/8.;
 }
 
 //svmDetector是HOGDescriptor内的一个成员变量,数据类型为向量vector。
 //用来保存hog特征用于svm分类时的系数的.
 //该函数返回为真的实际含义是什么呢?保证与hog特征长度相同,或者相差1,但为什么
 //相差1也可以呢?
 bool HOGDescriptor::checkDetectorSize() const
 {
     size_t detectorSize = svmDetector.size(), descriptorSize = getDescriptorSize();
     return detectorSize == 0 ||
         detectorSize == descriptorSize ||
         detectorSize == descriptorSize + 1;
 }
 
 void HOGDescriptor::setSVMDetector(InputArray _svmDetector)
 {  
     //这里的convertTo函数只是将图像Mat属性更改,比如说通道数,矩阵深度等。
     //这里是将输入的svm系数矩阵全部转换成浮点型。
     _svmDetector.getMat().convertTo(svmDetector, CV_32F);
     CV_Assert( checkDetectorSize() );
 }
 
 #define CV_TYPE_NAME_HOG_DESCRIPTOR "opencv-object-detector-hog"
 
 //FileNode是opencv的core中的一个文件存储节点类,这个节点用来存储读取到的每一个文件元素。
 //一般是读取XML和YAML格式的文件
 //又因为该函数是把文件节点中的内容读取到其类的成员变量中,所以函数后面不能有关键字const
 bool HOGDescriptor::read(FileNode& obj)
 {
     //isMap()是用来判断这个节点是不是一个映射类型,如果是映射类型,则每个节点都与
     //一个名字对应起来。因此这里的if语句的作用就是需读取的文件node是一个映射类型
     if( !obj.isMap() )
         return false;
     //中括号中的"winSize"是指返回名为winSize的一个节点,因为已经知道这些节点是mapping类型
     //也就是说都有一个对应的名字。
     FileNodeIterator it = obj["winSize"].begin();
     //操作符>>为从节点中读入数据,这里是将it指向的节点数据依次读入winSize.width,winSize.height
     //下面的几条语句功能类似
     it >> winSize.width >> winSize.height;
     it = obj["blockSize"].begin();
     it >> blockSize.width >> blockSize.height;
     it = obj["blockStride"].begin();
     it >> blockStride.width >> blockStride.height;
     it = obj["cellSize"].begin();
     it >> cellSize.width >> cellSize.height;
     obj["nbins"] >> nbins;
     obj["derivAperture"] >> derivAperture;
     obj["winSigma"] >> winSigma;
     obj["histogramNormType"] >> histogramNormType;
     obj["L2HysThreshold"] >> L2HysThreshold;
     obj["gammaCorrection"] >> gammaCorrection;
     obj["nlevels"] >> nlevels;
     
     //isSeq()是判断该节点内容是不是一个序列
     FileNode vecNode = obj["SVMDetector"];
     if( vecNode.isSeq() )
     {
         vecNode >> svmDetector;
         CV_Assert(checkDetectorSize());
     }
     //上面的都读取完了后就返回读取成功标志
     return true;
 }
     
 void HOGDescriptor::write(FileStorage& fs, const String& objName) const
 {
     //将objName名字输入到文件fs中
     if( !objName.empty() )
         fs << objName;
 
     fs << "{" CV_TYPE_NAME_HOG_DESCRIPTOR
     //下面几句依次将hog描述子内的变量输入到文件fs中,且每次输入前都输入
     //一个名字与其对应,因此这些节点是mapping类型。
     << "winSize" << winSize
     << "blockSize" << blockSize
     << "blockStride" << blockStride
     << "cellSize" << cellSize
     << "nbins" << nbins
     << "derivAperture" << derivAperture
     << "winSigma" << getWinSigma()
     << "histogramNormType" << histogramNormType
     << "L2HysThreshold" << L2HysThreshold
     << "gammaCorrection" << gammaCorrection
     << "nlevels" << nlevels;
     if( !svmDetector.empty() )
         //svmDetector则是直接输入序列,也有对应的名字。
         fs << "SVMDetector" << "[:" << svmDetector << "]";
     fs << "}";
 }
 
 //从给定的文件中读取参数
 bool HOGDescriptor::load(const String& filename, const String& objname)
 {
     FileStorage fs(filename, FileStorage::READ);
     //一个文件节点有很多叶子,所以一个文件节点包含了很多内容,这里当然是包含的
     //HOGDescriptor需要的各种参数了。
     FileNode obj = !objname.empty() ? fs[objname] : fs.getFirstTopLevelNode();
     return read(obj);
 }
 
 //将类中的参数以文件节点的形式写入文件中。
 void HOGDescriptor::save(const String& filename, const String& objName) const
 {
     FileStorage fs(filename, FileStorage::WRITE);
     write(fs, !objName.empty() ? objName : FileStorage::getDefaultObjectName(filename));
 }
 
 //复制HOG描述子到c中
 void HOGDescriptor::copyTo(HOGDescriptor& c) const
 {
     c.winSize = winSize;
     c.blockSize = blockSize;
     c.blockStride = blockStride;
     c.cellSize = cellSize;
     c.nbins = nbins;
     c.derivAperture = derivAperture;
     c.winSigma = winSigma;
     c.histogramNormType = histogramNormType;
     c.L2HysThreshold = L2HysThreshold;
     c.gammaCorrection = gammaCorrection;
     //vector类型也可以用等号赋值
     c.svmDetector = svmDetector; c.nlevels = nlevels; } 
 
 //计算图像img的梯度幅度图像grad和梯度方向图像qangle.
 //paddingTL为需要在原图像img左上角扩增的尺寸,同理paddingBR
 //为需要在img图像右下角扩增的尺寸。
 void HOGDescriptor::computeGradient(const Mat& img, Mat& grad, Mat& qangle,
                                     Size paddingTL, Size paddingBR) const
 {
     //该函数只能计算8位整型深度的单通道或者3通道图像.
     CV_Assert( img.type() == CV_8U || img.type() == CV_8UC3 );
 
     //将图像按照输入参数进行扩充,这里不是为了计算边缘梯度而做的扩充,因为
     //为了边缘梯度而扩充是在后面的代码完成的,所以这里为什么扩充暂时还不明白。
     Size gradsize(img.cols + paddingTL.width + paddingBR.width,
                   img.rows + paddingTL.height + paddingBR.height);
     grad.create(gradsize, CV_32FC2);  // <magnitude*(1-alpha), magnitude*alpha>
     qangle.create(gradsize, CV_8UC2); // [0..nbins-1] - quantized gradient orientation
     Size wholeSize;
     Point roiofs;
     //locateROI在此处是如果img图像是从其它父图像中某一部分得来的,那么其父图像
     //的大小尺寸就为wholeSize了,img图像左上角相对于父图像的位置点就为roiofs了。
     //对于正样本,其父图像就是img了,所以这里的wholeSize就和img.size()是一样的,
     //对应负样本,这2者不同;因为里面的关系比较不好懂,这里权且将wholesSize理解为
     //img的size,所以roiofs就应当理解为Point(0, 0)了。
     img.locateROI(wholeSize, roiofs);
 
     int i, x, y;
     int cn = img.channels();
 
     //_lut为行向量,用来作为浮点像素值的存储查找表
     Mat_<float> _lut(1, 256);
     const float* lut = &_lut(0,0);
 
     //gamma校正指的是将0~256的像素值全部开根号,即范围缩小了,且变换范围都不成线性了,
     if( gammaCorrection )
         for( i = 0; i < 256; i++ )
             _lut(0,i) = std::sqrt((float)i);
     else
         for( i = 0; i < 256; i++ )
             _lut(0,i) = (float)i;
 
     //创建长度为gradsize.width+gradsize.height+4的整型buffer
     AutoBuffer<int> mapbuf(gradsize.width + gradsize.height + 4);
     int* xmap = (int*)mapbuf + 1;
     int* ymap = xmap + gradsize.width + 2; 
 
     //言外之意思borderType就等于4了,因为opencv的源码中是如下定义的。
     //#define IPL_BORDER_REFLECT_101    4
     //enum{...,BORDER_REFLECT_101=IPL_BORDER_REFLECT_101,...}
     //borderType为边界扩充后所填充像素点的方式。   
     /*
     Various border types, image boundaries are denoted with '|'
 
     * BORDER_REPLICATE:     aaaaaa|abcdefgh|hhhhhhh
     * BORDER_REFLECT:       fedcba|abcdefgh|hgfedcb
     * BORDER_REFLECT_101:   gfedcb|abcdefgh|gfedcba
     * BORDER_WRAP:          cdefgh|abcdefgh|abcdefg        
     * BORDER_CONSTANT:      iiiiii|abcdefgh|iiiiiii  with some specified 'i'
    */
     const int borderType = (int)BORDER_REFLECT_101;
 
     for( x = -1; x < gradsize.width + 1; x++ )
     /*int borderInterpolate(int p, int len, int borderType)
       其中参数p表示的是扩充后图像的一个坐标,相对于对应的坐标轴而言;
       len参数表示对应源图像的一个坐标轴的长度;borderType为扩充类型,
       在上面已经有过介绍.
       所以这个函数的作用是从扩充后的像素点坐标推断出源图像中对应该点
       的坐标值。
    */
     //这里的xmap和ymap实际含义是什么呢?其实xmap向量里面存的就是
     //扩充后图像第一行像素点对应与原图像img中的像素横坐标,可以看
         //出,xmap向量中有些元素的值是相同的,因为扩充图像肯定会对应
         //到原图像img中的某一位置,而img本身尺寸内的像素也会对应该位置。
         //同理,ymap向量里面存的是扩充后图像第一列像素点对应于原图想img
         //中的像素纵坐标。
         xmap[x] = borderInterpolate(x - paddingTL.width + roiofs.x,
                         wholeSize.width, borderType) - roiofs.x;
     for( y = -1; y < gradsize.height + 1; y++ )
         ymap[y] = borderInterpolate(y - paddingTL.height + roiofs.y,
                         wholeSize.height, borderType) - roiofs.y;
 
     // x- & y- derivatives for the whole row
     int width = gradsize.width;
     AutoBuffer<float> _dbuf(width*4);
     float* dbuf = _dbuf;
     //DX为水平梯度图,DY为垂直梯度图,Mag为梯度幅度图,Angle为梯度角度图
     //该构造方法的第4个参数表示矩阵Mat的数据在内存中存放的位置。由此可以
     //看出,这4幅图像在内存中是连续存储的。
     Mat Dx(1, width, CV_32F, dbuf);
     Mat Dy(1, width, CV_32F, dbuf + width);
     Mat Mag(1, width, CV_32F, dbuf + width*2);
     Mat Angle(1, width, CV_32F, dbuf + width*3);
 
     int _nbins = nbins;
     //angleScale==9/pi;
     float angleScale = (float)(_nbins/CV_PI);
 #ifdef HAVE_IPP
     Mat lutimg(img.rows,img.cols,CV_MAKETYPE(CV_32F,cn));
     Mat hidxs(1, width, CV_32F);
     Ipp32f* pHidxs  = (Ipp32f*)hidxs.data;
     Ipp32f* pAngles = (Ipp32f*)Angle.data;
 
     IppiSize roiSize;
     roiSize.width = img.cols;
     roiSize.height = img.rows;
 
     for( y = 0; y < roiSize.height; y++ )
     {
        const uchar* imgPtr = img.data + y*img.step;
        float* imglutPtr = (float*)(lutimg.data + y*lutimg.step);
 
        for( x = 0; x < roiSize.width*cn; x++ )
        {
           imglutPtr[x] = lut[imgPtr[x]];
        }
     }
 
 #endif
     for( y = 0; y < gradsize.height; y++ )
     {
 #ifdef HAVE_IPP
         const float* imgPtr  = (float*)(lutimg.data + lutimg.step*ymap[y]);
         const float* prevPtr = (float*)(lutimg.data + lutimg.step*ymap[y-1]);
         const float* nextPtr = (float*)(lutimg.data + lutimg.step*ymap[y+1]);
 #else
     //imgPtr在这里指的是img图像的第y行首地址;prePtr指的是img第y-1行首地址;
     //nextPtr指的是img第y+1行首地址;
         const uchar* imgPtr  = img.data + img.step*ymap[y];
         const uchar* prevPtr = img.data + img.step*ymap[y-1];
         const uchar* nextPtr = img.data + img.step*ymap[y+1];
 #endif
         float* gradPtr = (float*)grad.ptr(y);
         uchar* qanglePtr = (uchar*)qangle.ptr(y);
     
     //输入图像img为单通道图像时的计算
         if( cn == 1 )
         {
             for( x = 0; x < width; x++ )
             {
                 int x1 = xmap[x];
 #ifdef HAVE_IPP
                 dbuf[x] = (float)(imgPtr[xmap[x+1]] - imgPtr[xmap[x-1]]);
                 dbuf[width + x] = (float)(nextPtr[x1] - prevPtr[x1]);
 #else
         //下面2句把Dx,Dy就计算出来了,因为其对应的内存都在dbuf中
                 dbuf[x] = (float)(lut[imgPtr[xmap[x+1]]] - lut[imgPtr[xmap[x-1]]]);
                 dbuf[width + x] = (float)(lut[nextPtr[x1]] - lut[prevPtr[x1]]);
 #endif
             }
         }
     //当cn==3时,也就是输入图像为3通道图像时的处理。
         else
         {
             for( x = 0; x < width; x++ )
             {
         //x1表示第y行第x1列的地址
                 int x1 = xmap[x]*3;
                 float dx0, dy0, dx, dy, mag0, mag;
 #ifdef HAVE_IPP
                 const float* p2 = imgPtr + xmap[x+1]*3;
                 const float* p0 = imgPtr + xmap[x-1]*3;
 
                 dx0 = p2[2] - p0[2];
                 dy0 = nextPtr[x1+2] - prevPtr[x1+2];
                 mag0 = dx0*dx0 + dy0*dy0;
 
                 dx = p2[1] - p0[1];
                 dy = nextPtr[x1+1] - prevPtr[x1+1];
                 mag = dx*dx + dy*dy;
 
                 if( mag0 < mag )
                 {
                     dx0 = dx;
                     dy0 = dy;
                     mag0 = mag;
                 }
 
                 dx = p2[0] - p0[0];
                 dy = nextPtr[x1] - prevPtr[x1];
                 mag = dx*dx + dy*dy;
 #else
         //p2为第y行第x+1列的地址
         //p0为第y行第x-1列的地址
                 const uchar* p2 = imgPtr + xmap[x+1]*3;
                 const uchar* p0 = imgPtr + xmap[x-1]*3;
         
         //计算第2通道的幅值
                 dx0 = lut[p2[2]] - lut[p0[2]];
                 dy0 = lut[nextPtr[x1+2]] - lut[prevPtr[x1+2]];
                 mag0 = dx0*dx0 + dy0*dy0;
 
         //计算第1通道的幅值
                 dx = lut[p2[1]] - lut[p0[1]];
                 dy = lut[nextPtr[x1+1]] - lut[prevPtr[x1+1]];
                 mag = dx*dx + dy*dy;
 
         //取幅值最大的那个通道
                 if( mag0 < mag )
                 {
                     dx0 = dx;
                     dy0 = dy;
                     mag0 = mag;
                 }
 
         //计算第0通道的幅值
                 dx = lut[p2[0]] - lut[p0[0]];
                 dy = lut[nextPtr[x1]] - lut[prevPtr[x1]];
                 mag = dx*dx + dy*dy;
  #endif
         //取幅值最大的那个通道
                 if( mag0 < mag )
                 {
                     dx0 = dx;
                     dy0 = dy;
                     mag0 = mag;
                 }
 
                 //最后求出水平和垂直方向上的梯度图像
         dbuf[x] = dx0;
                 dbuf[x+width] = dy0;
             }
         }
 #ifdef HAVE_IPP
         ippsCartToPolar_32f((const Ipp32f*)Dx.data, (const Ipp32f*)Dy.data, (Ipp32f*)Mag.data, pAngles, width);
         for( x = 0; x < width; x++ )
         {
            if(pAngles[x] < 0.f)
              pAngles[x] += (Ipp32f)(CV_PI*2.);
         }
 
         ippsNormalize_32f(pAngles, pAngles, width, 0.5f/angleScale, 1.f/angleScale);
         ippsFloor_32f(pAngles,(Ipp32f*)hidxs.data,width);
         ippsSub_32f_I((Ipp32f*)hidxs.data,pAngles,width);
         ippsMul_32f_I((Ipp32f*)Mag.data,pAngles,width);
 
         ippsSub_32f_I(pAngles,(Ipp32f*)Mag.data,width);
         ippsRealToCplx_32f((Ipp32f*)Mag.data,pAngles,(Ipp32fc*)gradPtr,width);
 #else
     //cartToPolar()函数是计算2个矩阵对应元素的幅度和角度,最后一个参数为是否
     //角度使用度数表示,这里为false表示不用度数表示,即用弧度表示。
     //如果只需计算2个矩阵对应元素的幅度图像,可以采用magnitude()函数。
     //-pi/2<Angle<pi/2;
         cartToPolar( Dx, Dy, Mag, Angle, false );
 #endif
         for( x = 0; x < width; x++ )
         {
 #ifdef HAVE_IPP
             int hidx = (int)pHidxs[x];
 #else
         //-5<angle<4
             float mag = dbuf[x+width*2], angle = dbuf[x+width*3]*angleScale - 0.5f;
             //cvFloor()返回不大于参数的最大整数
         //hidx={-5,-4,-3,-2,-1,0,1,2,3,4};
             int hidx = cvFloor(angle);
             //0<=angle<1;angle表示的意思是与其相邻的较小的那个bin的弧度距离(即弧度差)
             angle -= hidx;
             //gradPtr为grad图像的指针
         //gradPtr[x*2]表示的是与x处梯度方向相邻较小的那个bin的幅度权重;
         //gradPtr[x*2+1]表示的是与x处梯度方向相邻较大的那个bin的幅度权重
         gradPtr[x*2] = mag*(1.f - angle);
             gradPtr[x*2+1] = mag*angle;
 #endif
             if( hidx < 0 )
                 hidx += _nbins;
             else if( hidx >= _nbins )
                 hidx -= _nbins;
             assert( (unsigned)hidx < (unsigned)_nbins );
 
             qanglePtr[x*2] = (uchar)hidx;
             hidx++;
             //-1在补码中的表示为11111111,与-1相与的话就是自己本身了;
         //0在补码中的表示为00000000,与0相与的结果就是0了.
             hidx &= hidx < _nbins ? -1 : 0;
             qanglePtr[x*2+1] = (uchar)hidx;
         }
     }
 }
 
 
 struct HOGCache
 {
     struct BlockData
     {
         BlockData() : histOfs(0), imgOffset() {}
         int histOfs;
         Point imgOffset;
     };
 
     struct PixData
     {
         size_t gradOfs, qangleOfs;
         int histOfs[4];
         float histWeights[4];
         float gradWeight;
     };
 
     HOGCache();
     HOGCache(const HOGDescriptor* descriptor,
         const Mat& img, Size paddingTL, Size paddingBR,
         bool useCache, Size cacheStride);
     virtual ~HOGCache() {};
     virtual void init(const HOGDescriptor* descriptor,
         const Mat& img, Size paddingTL, Size paddingBR,
         bool useCache, Size cacheStride);
 
     Size windowsInImage(Size imageSize, Size winStride) const;
     Rect getWindow(Size imageSize, Size winStride, int idx) const;
 
     const float* getBlock(Point pt, float* buf);
     virtual void normalizeBlockHistogram(float* histogram) const;
 
     vector<PixData> pixData;
     vector<BlockData> blockData;
 
     bool useCache;
     vector<int> ymaxCached;
     Size winSize, cacheStride;
     Size nblocks, ncells;
     int blockHistogramSize;
     int count1, count2, count4;
     Point imgoffset;
     Mat_<float> blockCache;
     Mat_<uchar> blockCacheFlags;
 
     Mat grad, qangle;
     const HOGDescriptor* descriptor;
 };
 
 //默认的构造函数,不使用cache,块的直方图向量大小为0等
 HOGCache::HOGCache()
 {
     useCache = false;
     blockHistogramSize = count1 = count2 = count4 = 0;
     descriptor = 0;
 }
 
 //带参的初始化函数,采用内部的init函数进行初始化
 HOGCache::HOGCache(const HOGDescriptor* _descriptor,
         const Mat& _img, Size _paddingTL, Size _paddingBR,
         bool _useCache, Size _cacheStride)
 {
     init(_descriptor, _img, _paddingTL, _paddingBR, _useCache, _cacheStride);
 }
 
 //HOGCache结构体的初始化函数
 void HOGCache::init(const HOGDescriptor* _descriptor,
         const Mat& _img, Size _paddingTL, Size _paddingBR,
         bool _useCache, Size _cacheStride)
 {
     descriptor = _descriptor;
     cacheStride = _cacheStride;
     useCache = _useCache;
 
     //首先调用computeGradient()函数计算输入图像的权值梯度幅度图和角度量化图
     descriptor->computeGradient(_img, grad, qangle, _paddingTL, _paddingBR);
     //imgoffset是Point类型,而_paddingTL是Size类型,虽然类型不同,但是2者都是
     //一个二维坐标,所以是在opencv中是允许直接赋值的。
     imgoffset = _paddingTL;
 
     winSize = descriptor->winSize;
     Size blockSize = descriptor->blockSize;
     Size blockStride = descriptor->blockStride;
     Size cellSize = descriptor->cellSize;
     int i, j, nbins = descriptor->nbins;
     //rawBlockSize为block中包含像素点的个数
     int rawBlockSize = blockSize.width*blockSize.height;
     
     //nblocks为Size类型,其长和宽分别表示一个窗口中水平方向和垂直方向上block的
     //个数(需要考虑block在窗口中的移动)
     nblocks = Size((winSize.width - blockSize.width)/blockStride.width + 1,
                    (winSize.height - blockSize.height)/blockStride.height + 1);
     //ncells也是Size类型,其长和宽分别表示一个block中水平方向和垂直方向容纳下
     //的cell个数
     ncells = Size(blockSize.width/cellSize.width, blockSize.height/cellSize.height);
     //blockHistogramSize表示一个block中贡献给hog描述子向量的长度
     blockHistogramSize = ncells.width*ncells.height*nbins;
 
     if( useCache )
     {
         //cacheStride= _cacheStride,即其大小是由参数传入的,表示的是窗口移动的大小
         //cacheSize长和宽表示扩充后的图像cache中,block在水平方向和垂直方向出现的个数
         Size cacheSize((grad.cols - blockSize.width)/cacheStride.width+1,
                        (winSize.height/cacheStride.height)+1);
         //blockCache为一个float型的Mat,注意其列数的值
         blockCache.create(cacheSize.height, cacheSize.width*blockHistogramSize);
         //blockCacheFlags为一个uchar型的Mat
         blockCacheFlags.create(cacheSize);
         size_t cacheRows = blockCache.rows;
         //ymaxCached为vector<int>类型
         //Mat::resize()为矩阵的一个方法,只是改变矩阵的行数,与单独的resize()函数不相同。
         ymaxCached.resize(cacheRows);
         //ymaxCached向量内部全部初始化为-1
         for(size_t ii = 0; ii < cacheRows; ii++ )
             ymaxCached[ii] = -1;
     }
     
     //weights为一个尺寸为blockSize的二维高斯表,下面的代码就是计算二维高斯的系数
     Mat_<float> weights(blockSize);
     float sigma = (float)descriptor->getWinSigma();
     float scale = 1.f/(sigma*sigma*2);
 
     for(i = 0; i < blockSize.height; i++)
         for(j = 0; j < blockSize.width; j++)
         {
             float di = i - blockSize.height*0.5f;
             float dj = j - blockSize.width*0.5f;
             weights(i,j) = std::exp(-(di*di + dj*dj)*scale);
         }
 
     //vector<BlockData> blockData;而BlockData为HOGCache的一个结构体成员
     //nblocks.width*nblocks.height表示一个检测窗口中block的个数,
     //而cacheSize.width*cacheSize.heigh表示一个已经扩充的图片中的block的个数
     blockData.resize(nblocks.width*nblocks.height);
     //vector<PixData> pixData;同理,Pixdata也为HOGCache中的一个结构体成员
     //rawBlockSize表示每个block中像素点的个数
     //resize表示将其转换成列向量
     pixData.resize(rawBlockSize*3);
 
     // Initialize 2 lookup tables, pixData & blockData.
     // Here is why:
     //
     // The detection algorithm runs in 4 nested loops (at each pyramid layer):
     //  loop over the windows within the input image
     //    loop over the blocks within each window
     //      loop over the cells within each block
     //        loop over the pixels in each cell
     //
     // As each of the loops runs over a 2-dimensional array,
     // we could get 8(!) nested loops in total, which is very-very slow.
     //
     // To speed the things up, we do the following:
     //   1. loop over windows is unrolled in the HOGDescriptor::{compute|detect} methods;
     //         inside we compute the current search window using getWindow() method.
     //         Yes, it involves some overhead (function call + couple of divisions),
     //         but it's tiny in fact.
     //   2. loop over the blocks is also unrolled. Inside we use pre-computed blockData[j]
     //         to set up gradient and histogram pointers.
     //   3. loops over cells and pixels in each cell are merged
     //       (since there is no overlap between cells, each pixel in the block is processed once)
     //      and also unrolled. Inside we use PixData[k] to access the gradient values and
     //      update the histogram
     //count1,count2,count4分别表示block中同时对1个cell,2个cell,4个cell有贡献的像素点的个数。
     count1 = count2 = count4 = 0;
     for( j = 0; j < blockSize.width; j++ )
         for( i = 0; i < blockSize.height; i++ )
         {
             PixData* data = 0;
             //cellX和cellY表示的是block内该像素点所在的cell横坐标和纵坐标索引,以小数的形式存在。
             float cellX = (j+0.5f)/cellSize.width - 0.5f;
             float cellY = (i+0.5f)/cellSize.height - 0.5f;
             //cvRound返回最接近参数的整数;cvFloor返回不大于参数的整数;cvCeil返回不小于参数的整数
             //icellX0和icellY0表示所在cell坐标索引,索引值为该像素点相邻cell的那个较小的cell索引
             //当然此处就是由整数的形式存在了。
             //按照默认的系数的话,icellX0和icellY0只可能取值-1,0,1,且当i和j<3.5时对应的值才取-1
             //当i和j>11.5时取值为1,其它时刻取值为0(注意i,j最大是15,从0开始的)
             int icellX0 = cvFloor(cellX);
             int icellY0 = cvFloor(cellY);
             int icellX1 = icellX0 + 1, icellY1 = icellY0 + 1;
             //此处的cellx和celly表示的是真实索引值与最近邻cell索引值之间的差,
             //为后面计算同一像素对不同cell中的hist权重的计算。
             cellX -= icellX0;
             cellY -= icellY0;
       
                //满足这个if条件说明icellX0只能为0,也就是说block横坐标在(3.5,11.5)之间时
             if( (unsigned)icellX0 < (unsigned)ncells.width &&
                 (unsigned)icellX1 < (unsigned)ncells.width )
             {
                //满足这个if条件说明icellY0只能为0,也就是说block纵坐标在(3.5,11.5)之间时
                 if( (unsigned)icellY0 < (unsigned)ncells.height &&
                     (unsigned)icellY1 < (unsigned)ncells.height )
                 {
                     //同时满足上面2个if语句的像素对4个cell都有权值贡献
                     //rawBlockSize表示的是1个block中存储像素点的个数
                     //而pixData的尺寸大小为block中像素点的3倍,其定义如下:
                     //pixData.resize(rawBlockSize*3);
                     //pixData的前面block像素大小的内存为存储只对block中一个cell
                     //有贡献的pixel;中间block像素大小的内存存储对block中同时2个
                     //cell有贡献的pixel;最后面的为对block中同时4个cell都有贡献
                     //的pixel
                     data = &pixData[rawBlockSize*2 + (count4++)];
                     //下面计算出的结果为0
                     data->histOfs[0] = (icellX0*ncells.height + icellY0)*nbins;
                      //为该像素点对cell0的权重
                     data->histWeights[0] = (1.f - cellX)*(1.f - cellY);
                     //下面计算出的结果为18
                     data->histOfs[1] = (icellX1*ncells.height + icellY0)*nbins;
                     data->histWeights[1] = cellX*(1.f - cellY);
                     //下面计算出的结果为9
                     data->histOfs[2] = (icellX0*ncells.height + icellY1)*nbins;
                     data->histWeights[2] = (1.f - cellX)*cellY;
                     //下面计算出的结果为27
                     data->histOfs[3] = (icellX1*ncells.height + icellY1)*nbins;
                     data->histWeights[3] = cellX*cellY;
                 }
                 else
                    //满足这个else条件说明icellY0取-1或者1,也就是说block纵坐标在(0, 3.5)
                 //和(11.5, 15)之间.
                 //此时的像素点对相邻的2个cell有权重贡献
                 {
                     data = &pixData[rawBlockSize + (count2++)];                    
                     if( (unsigned)icellY0 < (unsigned)ncells.height )
                     {
                         //(unsigned)-1等于127>2,所以此处满足if条件时icellY0==1;
                         //icellY1==1;
                         icellY1 = icellY0;
                         cellY = 1.f - cellY;
                     }
                     //不满足if条件时,icellY0==-1;icellY1==0;
                     //当然了,这2种情况下icellX0==0;icellX1==1;
                     data->histOfs[0] = (icellX0*ncells.height + icellY1)*nbins;
                     data->histWeights[0] = (1.f - cellX)*cellY;
                     data->histOfs[1] = (icellX1*ncells.height + icellY1)*nbins;
                     data->histWeights[1] = cellX*cellY;
                     data->histOfs[2] = data->histOfs[3] = 0;
                     data->histWeights[2] = data->histWeights[3] = 0;
                 }
             }
             //当block中横坐标满足在(0, 3.5)和(11.5, 15)范围内时,即
             //icellX0==-1或==1
             else
             {
                 
                 if( (unsigned)icellX0 < (unsigned)ncells.width )
                 {
                     //icellX1=icllX0=1;
                     icellX1 = icellX0;
                     cellX = 1.f - cellX;
                 }
                 //当icllY0=0时,此时对2个cell有贡献
                 if( (unsigned)icellY0 < (unsigned)ncells.height &&
                     (unsigned)icellY1 < (unsigned)ncells.height )
                 {                    
                     data = &pixData[rawBlockSize + (count2++)];
                     data->histOfs[0] = (icellX1*ncells.height + icellY0)*nbins;
                     data->histWeights[0] = cellX*(1.f - cellY);
                     data->histOfs[1] = (icellX1*ncells.height + icellY1)*nbins;
                     data->histWeights[1] = cellX*cellY;
                     data->histOfs[2] = data->histOfs[3] = 0;
                     data->histWeights[2] = data->histWeights[3] = 0;
                 }
                 else
                 //此时只对自身的cell有贡献
                 {
                     data = &pixData[count1++];
                     if( (unsigned)icellY0 < (unsigned)ncells.height )
                     {
                         icellY1 = icellY0;
                         cellY = 1.f - cellY;
                     }
                     data->histOfs[0] = (icellX1*ncells.height + icellY1)*nbins;
                     data->histWeights[0] = cellX*cellY;
                     data->histOfs[1] = data->histOfs[2] = data->histOfs[3] = 0;
                     data->histWeights[1] = data->histWeights[2] = data->histWeights[3] = 0;
                 }
             }
             //为什么每个block中i,j位置的gradOfs和qangleOfs都相同且是如下的计算公式呢?
             //那是因为输入的_img参数不是代表整幅图片而是检测窗口大小的图片,所以每个
             //检测窗口中关于block的信息可以看做是相同的
             data->gradOfs = (grad.cols*i + j)*2;
             data->qangleOfs = (qangle.cols*i + j)*2;
             //每个block中i,j位置的权重都是固定的
             data->gradWeight = weights(i,j);
         }
 
     //保证所有的点都被扫描了一遍
     assert( count1 + count2 + count4 == rawBlockSize );
     // defragment pixData
     //将pixData中按照内存排满,这样节省了2/3的内存
     for( j = 0; j < count2; j++ )
         pixData[j + count1] = pixData[j + rawBlockSize];
     for( j = 0; j < count4; j++ )
         pixData[j + count1 + count2] = pixData[j + rawBlockSize*2];
     //此时count2表示至多对2个cell有贡献的所有像素点的个数
     count2 += count1;
     //此时count4表示至多对4个cell有贡献的所有像素点的个数
     count4 += count2;
 
     //上面是初始化pixData,下面开始初始化blockData
     // initialize blockData
     for( j = 0; j < nblocks.width; j++ )
         for( i = 0; i < nblocks.height; i++ )
         {
             BlockData& data = blockData[j*nblocks.height + i];
             //histOfs表示该block对检测窗口贡献的hog描述变量起点在整个
             //变量中的坐标
             data.histOfs = (j*nblocks.height + i)*blockHistogramSize;
             //imgOffset表示该block的左上角在检测窗口中的坐标
             data.imgOffset = Point(j*blockStride.width,i*blockStride.height);
         }
         //一个检测窗口对应一个blockData内存,一个block对应一个pixData内存。
 }
 
 
 //pt为该block左上角在滑动窗口中的坐标,buf为指向检测窗口中blocData的指针
 //函数返回一个block描述子的指针
 const float* HOGCache::getBlock(Point pt, float* buf)
 {
     float* blockHist = buf;
     assert(descriptor != 0);
 
     Size blockSize = descriptor->blockSize;
     pt += imgoffset;
 
     CV_Assert( (unsigned)pt.x <= (unsigned)(grad.cols - blockSize.width) &&
                (unsigned)pt.y <= (unsigned)(grad.rows - blockSize.height) );
 
     if( useCache )
     {
         //cacheStride可以认为和blockStride是一样的
         //保证所获取到HOGCache是我们所需要的,即在block移动过程中会出现
         CV_Assert( pt.x % cacheStride.width == 0 &&
                    pt.y % cacheStride.height == 0 );
         //cacheIdx表示的是block个数的坐标
         Point cacheIdx(pt.x/cacheStride.width,
                       (pt.y/cacheStride.height) % blockCache.rows);
         //ymaxCached的长度为一个检测窗口垂直方向上容纳的block个数
         if( pt.y != ymaxCached[cacheIdx.y] )
         {
             //取出blockCacheFlags的第cacheIdx.y行并且赋值为0
             Mat_<uchar> cacheRow = blockCacheFlags.row(cacheIdx.y);
             cacheRow = (uchar)0;
             ymaxCached[cacheIdx.y] = pt.y;
         }
 
         //blockHist指向该点对应block所贡献的hog描述子向量,初始值为空
         blockHist = &blockCache[cacheIdx.y][cacheIdx.x*blockHistogramSize];
         uchar& computedFlag = blockCacheFlags(cacheIdx.y, cacheIdx.x);
         if( computedFlag != 0 )
             return blockHist;
         computedFlag = (uchar)1; // set it at once, before actual computing
     }
 
     int k, C1 = count1, C2 = count2, C4 = count4;
     //
     const float* gradPtr = (const float*)(grad.data + grad.step*pt.y) + pt.x*2;
     const uchar* qanglePtr = qangle.data + qangle.step*pt.y + pt.x*2;
 
     CV_Assert( blockHist != 0 );
 #ifdef HAVE_IPP
     ippsZero_32f(blockHist,blockHistogramSize);
 #else
     for( k = 0; k < blockHistogramSize; k++ )
         blockHist[k] = 0.f;
 #endif
 
     const PixData* _pixData = &pixData[0];
 
     //C1表示只对自己所在cell有贡献的点的个数
     for( k = 0; k < C1; k++ )
     {
         const PixData& pk = _pixData[k];
         //a表示的是幅度指针
         const float* a = gradPtr + pk.gradOfs;
         float w = pk.gradWeight*pk.histWeights[0];
         //h表示的是相位指针
         const uchar* h = qanglePtr + pk.qangleOfs;
 
         //幅度有2个通道是因为每个像素点的幅值被分解到了其相邻的两个bin上了
         //相位有2个通道是因为每个像素点的相位的相邻处都有的2个bin的序号
         int h0 = h[0], h1 = h[1];
         float* hist = blockHist + pk.histOfs[0];
         float t0 = hist[h0] + a[0]*w;
         float t1 = hist[h1] + a[1]*w;
         //hist中放的为加权的梯度值
         hist[h0] = t0; hist[h1] = t1;
     }
 
     for( ; k < C2; k++ )
     {
         const PixData& pk = _pixData[k];
         const float* a = gradPtr + pk.gradOfs;
         float w, t0, t1, a0 = a[0], a1 = a[1];
         const uchar* h = qanglePtr + pk.qangleOfs;
         int h0 = h[0], h1 = h[1];
 
         //因为此时的像素对2个cell有贡献,这是其中一个cell的贡献
         float* hist = blockHist + pk.histOfs[0];
         w = pk.gradWeight*pk.histWeights[0];
         t0 = hist[h0] + a0*w;
         t1 = hist[h1] + a1*w;
         hist[h0] = t0; hist[h1] = t1;
 
         //另一个cell的贡献
         hist = blockHist + pk.histOfs[1];
         w = pk.gradWeight*pk.histWeights[1];
         t0 = hist[h0] + a0*w;
         t1 = hist[h1] + a1*w;
         hist[h0] = t0; hist[h1] = t1;
     }
 
     //和上面类似
     for( ; k < C4; k++ )
     {
         const PixData& pk = _pixData[k];
         const float* a = gradPtr + pk.gradOfs;
         float w, t0, t1, a0 = a[0], a1 = a[1];
         const uchar* h = qanglePtr + pk.qangleOfs;
         int h0 = h[0], h1 = h[1];
 
         float* hist = blockHist + pk.histOfs[0];
         w = pk.gradWeight*pk.histWeights[0];
         t0 = hist[h0] + a0*w;
         t1 = hist[h1] + a1*w;
         hist[h0] = t0; hist[h1] = t1;
 
         hist = blockHist + pk.histOfs[1];
         w = pk.gradWeight*pk.histWeights[1];
         t0 = hist[h0] + a0*w;
         t1 = hist[h1] + a1*w;
         hist[h0] = t0; hist[h1] = t1;
 
         hist = blockHist + pk.histOfs[2];
         w = pk.gradWeight*pk.histWeights[2];
         t0 = hist[h0] + a0*w;
         t1 = hist[h1] + a1*w;
         hist[h0] = t0; hist[h1] = t1;
 
         hist = blockHist + pk.histOfs[3];
         w = pk.gradWeight*pk.histWeights[3];
         t0 = hist[h0] + a0*w;
         t1 = hist[h1] + a1*w;
         hist[h0] = t0; hist[h1] = t1;
     }
 
     normalizeBlockHistogram(blockHist);
 
     return blockHist;
 }
 
 
 void HOGCache::normalizeBlockHistogram(float* _hist) const
 {
     float* hist = &_hist[0];
 #ifdef HAVE_IPP
     size_t sz = blockHistogramSize;
 #else
     size_t i, sz = blockHistogramSize;
 #endif
 
     float sum = 0;
 #ifdef HAVE_IPP
     ippsDotProd_32f(hist,hist,sz,&sum);
 #else
     //第一次归一化求的是平方和
     for( i = 0; i < sz; i++ )
         sum += hist[i]*hist[i];
 #endif
     //分母为平方和开根号+0.1
     float scale = 1.f/(std::sqrt(sum)+sz*0.1f), thresh = (float)descriptor->L2HysThreshold;
 #ifdef HAVE_IPP
     ippsMulC_32f_I(scale,hist,sz);
     ippsThreshold_32f_I( hist, sz, thresh, ippCmpGreater );
     ippsDotProd_32f(hist,hist,sz,&sum);
 #else
     for( i = 0, sum = 0; i < sz; i++ )
     {
         //第2次归一化是在第1次的基础上继续求平和和
         hist[i] = std::min(hist[i]*scale, thresh);
         sum += hist[i]*hist[i];
     }
 #endif
 
     scale = 1.f/(std::sqrt(sum)+1e-3f);
 #ifdef HAVE_IPP
     ippsMulC_32f_I(scale,hist,sz);
 #else
     //最终归一化结果
     for( i = 0; i < sz; i++ )
         hist[i] *= scale;
 #endif
 }
 
 
 //返回测试图片中水平方向和垂直方向共有多少个检测窗口
 Size HOGCache::windowsInImage(Size imageSize, Size winStride) const
 {
     return Size((imageSize.width - winSize.width)/winStride.width + 1,
                 (imageSize.height - winSize.height)/winStride.height + 1);
 }
 
 
 //给定图片的大小,已经检测窗口滑动的大小和测试图片中的检测窗口的索引,得到该索引处
 //检测窗口的尺寸,包括坐标信息
 Rect HOGCache::getWindow(Size imageSize, Size winStride, int idx) const
 {
     int nwindowsX = (imageSize.width - winSize.width)/winStride.width + 1;
     int y = idx / nwindowsX;//商
     int x = idx - nwindowsX*y;//余数
     return Rect( x*winStride.width, y*winStride.height, winSize.width, winSize.height );
 }
 
 
 void HOGDescriptor::compute(const Mat& img, vector<float>& descriptors,
                             Size winStride, Size padding,
                             const vector<Point>& locations) const
 {
     //Size()表示长和宽都是0
     if( winStride == Size() )
         winStride = cellSize;
     //gcd为求最大公约数,如果采用默认值的话,则2者相同
     Size cacheStride(gcd(winStride.width, blockStride.width),
                      gcd(winStride.height, blockStride.height));
     size_t nwindows = locations.size();
     //alignSize(m, n)返回n的倍数大于等于m的最小值
     padding.width = (int)alignSize(std::max(padding.width, 0), cacheStride.width);
     padding.height = (int)alignSize(std::max(padding.height, 0), cacheStride.height);
     Size paddedImgSize(img.cols + padding.width*2, img.rows + padding.height*2);
 
     HOGCache cache(this, img, padding, padding, nwindows == 0, cacheStride);
 
     if( !nwindows )
         //Mat::area()表示为Mat的面积
         nwindows = cache.windowsInImage(paddedImgSize, winStride).area();
 
     const HOGCache::BlockData* blockData = &cache.blockData[0];
 
     int nblocks = cache.nblocks.area();
     int blockHistogramSize = cache.blockHistogramSize;
     size_t dsize = getDescriptorSize();//一个hog的描述长度
     //resize()为改变矩阵的行数,如果减少矩阵的行数则只保留减少后的
     //那些行,如果是增加行数,则保留所有的行。
     //这里将描述子长度扩展到整幅图片
     descriptors.resize(dsize*nwindows);
 
     for( size_t i = 0; i < nwindows; i++ )
     {
         //descriptor为第i个检测窗口的描述子首位置。
         float* descriptor = &descriptors[i*dsize];
        
         Point pt0;
         //非空
         if( !locations.empty() )
         {
             pt0 = locations[i];
             //非法的点
             if( pt0.x < -padding.width || pt0.x > img.cols + padding.width - winSize.width ||
                 pt0.y < -padding.height || pt0.y > img.rows + padding.height - winSize.height )
                 continue;
         }
         //locations为空
         else
         {
             //pt0为没有扩充前图像对应的第i个检测窗口
             pt0 = cache.getWindow(paddedImgSize, winStride, (int)i).tl() - Point(padding);
             CV_Assert(pt0.x % cacheStride.width == 0 && pt0.y % cacheStride.height == 0);
         }
 
         for( int j = 0; j < nblocks; j++ )
         {
             const HOGCache::BlockData& bj = blockData[j];
             //pt为block的左上角相对检测图片的坐标
             Point pt = pt0 + bj.imgOffset;
 
             //dst为该block在整个测试图片的描述子的位置
             float* dst = descriptor + bj.histOfs;
             const float* src = cache.getBlock(pt, dst);
             if( src != dst )
 #ifdef HAVE_IPP
                ippsCopy_32f(src,dst,blockHistogramSize);
 #else
                 for( int k = 0; k < blockHistogramSize; k++ )
                     dst[k] = src[k];
 #endif
         }
     }
 }
 
 
 void HOGDescriptor::detect(const Mat& img,
     vector<Point>& hits, vector<double>& weights, double hitThreshold, 
     Size winStride, Size padding, const vector<Point>& locations) const
 {
     //hits里面存的是符合检测到目标的窗口的左上角顶点坐标
     hits.clear();
     if( svmDetector.empty() )
         return;
 
     if( winStride == Size() )
         winStride = cellSize;
     Size cacheStride(gcd(winStride.width, blockStride.width),
                      gcd(winStride.height, blockStride.height));
     size_t nwindows = locations.size();
     padding.width = (int)alignSize(std::max(padding.width, 0), cacheStride.width);
     padding.height = (int)alignSize(std::max(padding.height, 0), cacheStride.height);
     Size paddedImgSize(img.cols + padding.width*2, img.rows + padding.height*2);
 
     HOGCache cache(this, img, padding, padding, nwindows == 0, cacheStride);
 
     if( !nwindows )
         nwindows = cache.windowsInImage(paddedImgSize, winStride).area();
 
     const HOGCache::BlockData* blockData = &cache.blockData[0];
 
     int nblocks = cache.nblocks.area();
     int blockHistogramSize = cache.blockHistogramSize;
     size_t dsize = getDescriptorSize();
 
     double rho = svmDetector.size() > dsize ? svmDetector[dsize] : 0;
     vector<float> blockHist(blockHistogramSize);
 
     for( size_t i = 0; i < nwindows; i++ )
     {
         Point pt0;
         if( !locations.empty() )
         {
             pt0 = locations[i];
             if( pt0.x < -padding.width || pt0.x > img.cols + padding.width - winSize.width ||
                 pt0.y < -padding.height || pt0.y > img.rows + padding.height - winSize.height )
                 continue;
         }
         else
         {
             pt0 = cache.getWindow(paddedImgSize, winStride, (int)i).tl() - Point(padding);
             CV_Assert(pt0.x % cacheStride.width == 0 && pt0.y % cacheStride.height == 0);
         }
         double s = rho;
         //svmVec指向svmDetector最前面那个元素
         const float* svmVec = &svmDetector[0];
 #ifdef HAVE_IPP
         int j;
 #else
         int j, k;
 #endif
         for( j = 0; j < nblocks; j++, svmVec += blockHistogramSize )
         {
             const HOGCache::BlockData& bj = blockData[j];
             Point pt = pt0 + bj.imgOffset;
             
             //vec为测试图片pt处的block贡献的描述子指针
             const float* vec = cache.getBlock(pt, &blockHist[0]);
 #ifdef HAVE_IPP
             Ipp32f partSum;
             ippsDotProd_32f(vec,svmVec,blockHistogramSize,&partSum);
             s += (double)partSum;
 #else
             for( k = 0; k <= blockHistogramSize - 4; k += 4 )
                 //const float* svmVec = &svmDetector[0];
                 s += vec[k]*svmVec[k] + vec[k+1]*svmVec[k+1] +
                     vec[k+2]*svmVec[k+2] + vec[k+3]*svmVec[k+3];
             for( ; k < blockHistogramSize; k++ )
                 s += vec[k]*svmVec[k];
 #endif
         }
         if( s >= hitThreshold )
         {
             hits.push_back(pt0);
             weights.push_back(s);
         }
     }
 }
 
 //不用保留检测到目标的可信度,即权重
 void HOGDescriptor::detect(const Mat& img, vector<Point>& hits, double hitThreshold, 
                            Size winStride, Size padding, const vector<Point>& locations) const
 {
     vector<double> weightsV;
     detect(img, hits, weightsV, hitThreshold, winStride, padding, locations);
 }
 
 struct HOGInvoker
 {
     HOGInvoker( const HOGDescriptor* _hog, const Mat& _img,
                 double _hitThreshold, Size _winStride, Size _padding,
                 const double* _levelScale, ConcurrentRectVector* _vec, 
                 ConcurrentDoubleVector* _weights=0, ConcurrentDoubleVector* _scales=0 ) 
     {
         hog = _hog;
         img = _img;
         hitThreshold = _hitThreshold;
         winStride = _winStride;
         padding = _padding;
         levelScale = _levelScale;
         vec = _vec;
         weights = _weights;
         scales = _scales;
     }
 
     void operator()( const BlockedRange& range ) const
     {
         int i, i1 = range.begin(), i2 = range.end();
         double minScale = i1 > 0 ? levelScale[i1] : i2 > 1 ? levelScale[i1+1] : std::max(img.cols, img.rows);
         //将原图片进行缩放
         Size maxSz(cvCeil(img.cols/minScale), cvCeil(img.rows/minScale));
         Mat smallerImgBuf(maxSz, img.type());
         vector<Point> locations;
         vector<double> hitsWeights;
 
         for( i = i1; i < i2; i++ )
         {
             double scale = levelScale[i];
             Size sz(cvRound(img.cols/scale), cvRound(img.rows/scale));
             //smallerImg只是构造一个指针,并没有复制数据
             Mat smallerImg(sz, img.type(), smallerImgBuf.data);
             //没有尺寸缩放
             if( sz == img.size() )
                 smallerImg = Mat(sz, img.type(), img.data, img.step);
             //有尺寸缩放
             else
                 resize(img, smallerImg, sz);
             //该函数实际上是将返回的值存在locations和histWeights中
             //其中locations存的是目标区域的左上角坐标
             hog->detect(smallerImg, locations, hitsWeights, hitThreshold, winStride, padding);
             Size scaledWinSize = Size(cvRound(hog->winSize.width*scale), cvRound(hog->winSize.height*scale));
             for( size_t j = 0; j < locations.size(); j++ )
             {
                 //保存目标区域
                 vec->push_back(Rect(cvRound(locations[j].x*scale),
                                     cvRound(locations[j].y*scale),
                                     scaledWinSize.width, scaledWinSize.height));
                 //保存缩放尺寸
                 if (scales) {
                     scales->push_back(scale);
                 }
             }
             //保存svm计算后的结果值
             if (weights && (!hitsWeights.empty()))
             {
                 for (size_t j = 0; j < locations.size(); j++)
                 {
                     weights->push_back(hitsWeights[j]);
                 }
             }        
         }
     }
 
     const HOGDescriptor* hog;
     Mat img;
     double hitThreshold;
     Size winStride;
     Size padding;
     const double* levelScale;
     //typedef tbb::concurrent_vector<Rect> ConcurrentRectVector;
     ConcurrentRectVector* vec;
     //typedef tbb::concurrent_vector<double> ConcurrentDoubleVector;
     ConcurrentDoubleVector* weights;
     ConcurrentDoubleVector* scales;
 };
 
 
 void HOGDescriptor::detectMultiScale(
     const Mat& img, vector<Rect>& foundLocations, vector<double>& foundWeights,
     double hitThreshold, Size winStride, Size padding,
     double scale0, double finalThreshold, bool useMeanshiftGrouping) const  
 {
     double scale = 1.;
     int levels = 0;
 
     vector<double> levelScale;
 
     //nlevels默认的是64层
     for( levels = 0; levels < nlevels; levels++ )
     {
         levelScale.push_back(scale);
         if( cvRound(img.cols/scale) < winSize.width ||
             cvRound(img.rows/scale) < winSize.height ||
             scale0 <= 1 )
             break;
         //只考虑测试图片尺寸比检测窗口尺寸大的情况
         scale *= scale0;
     }
     levels = std::max(levels, 1);
     levelScale.resize(levels);
 
     ConcurrentRectVector allCandidates;
     ConcurrentDoubleVector tempScales;
     ConcurrentDoubleVector tempWeights;
     vector<double> foundScales;
     
     //TBB并行计算
     parallel_for(BlockedRange(0, (int)levelScale.size()),
                  HOGInvoker(this, img, hitThreshold, winStride, padding, &levelScale[0], &allCandidates, &tempWeights, &tempScales));
     //将tempScales中的内容复制到foundScales中;back_inserter是指在指定参数迭代器的末尾插入数据
     std::copy(tempScales.begin(), tempScales.end(), back_inserter(foundScales));
     //容器的clear()方法是指移除容器中所有的数据
     foundLocations.clear();
     //将候选目标窗口保存在foundLocations中
     std::copy(allCandidates.begin(), allCandidates.end(), back_inserter(foundLocations));
     foundWeights.clear();
     //将候选目标可信度保存在foundWeights中
     std::copy(tempWeights.begin(), tempWeights.end(), back_inserter(foundWeights));
 
     if ( useMeanshiftGrouping )
     {
         groupRectangles_meanshift(foundLocations, foundWeights, foundScales, finalThreshold, winSize);
     }
     else
     {
         //对矩形框进行聚类
         groupRectangles(foundLocations, (int)finalThreshold, 0.2);
     }
 }
 
 //不考虑目标的置信度
 void HOGDescriptor::detectMultiScale(const Mat& img, vector<Rect>& foundLocations, 
                                      double hitThreshold, Size winStride, Size padding,
                                      double scale0, double finalThreshold, bool useMeanshiftGrouping) const  
 {
     vector<double> foundWeights;
     detectMultiScale(img, foundLocations, foundWeights, hitThreshold, winStride, 
                      padding, scale0, finalThreshold, useMeanshiftGrouping);
 }
 
 typedef RTTIImpl<HOGDescriptor> HOGRTTI;
 
 CvType hog_type( CV_TYPE_NAME_HOG_DESCRIPTOR, HOGRTTI::isInstance,
                  HOGRTTI::release, HOGRTTI::read, HOGRTTI::write, HOGRTTI::clone);
 
 vector<float> HOGDescriptor::getDefaultPeopleDetector()
 {
     static const float detector[] = {
        0.05359386f, -0.14721455f, -0.05532170f, 0.05077307f,
        0.11547081f, -0.04268804f, 0.04635834f, ........
   };
        //返回detector数组的从头到尾构成的向量
     return vector<float>(detector, detector + sizeof(detector)/sizeof(detector[0]));
 }
 //This function renurn 1981 SVM coeffs obtained from daimler's base. 
 //To use these coeffs the detection window size should be (48,96)  
 vector<float> HOGDescriptor::getDaimlerPeopleDetector()
 {
     static const float detector[] = {
         0.294350f, -0.098796f, -0.129522f, 0.078753f,
         0.387527f, 0.261529f, 0.145939f, 0.061520f,
       ........
         };
         //返回detector的首尾构成的向量
         return vector<float>(detector, detector + sizeof(detector)/sizeof(detector[0]));
 }
 
 }

objdetect.hpp中关于hog的部分:

 HOG (Histogram-of-Oriented-Gradients) Descriptor and Object Detector //
 
 struct CV_EXPORTS_W HOGDescriptor
 {
 public:
     enum { L2Hys=0 };
     enum { DEFAULT_NLEVELS=64 };
 
     CV_WRAP HOGDescriptor() : winSize(64,128), blockSize(16,16), blockStride(8,8),
         cellSize(8,8), nbins(9), derivAperture(1), winSigma(-1),
         histogramNormType(HOGDescriptor::L2Hys), L2HysThreshold(0.2), gammaCorrection(true),
         nlevels(HOGDescriptor::DEFAULT_NLEVELS)
     {}
 
     //可以用构造函数的参数来作为冒号外的参数初始化传入,这样定义该类的时候,一旦变量分配了
     //内存,则马上会被初始化,而不用等所有变量分配完内存后再初始化。
     CV_WRAP HOGDescriptor(Size _winSize, Size _blockSize, Size _blockStride,
                   Size _cellSize, int _nbins, int _derivAperture=1, double _winSigma=-1,
                   int _histogramNormType=HOGDescriptor::L2Hys,
                   double _L2HysThreshold=0.2, bool _gammaCorrection=false,
                   int _nlevels=HOGDescriptor::DEFAULT_NLEVELS)
     : winSize(_winSize), blockSize(_blockSize), blockStride(_blockStride), cellSize(_cellSize),
     nbins(_nbins), derivAperture(_derivAperture), winSigma(_winSigma),
     histogramNormType(_histogramNormType), L2HysThreshold(_L2HysThreshold),
     gammaCorrection(_gammaCorrection), nlevels(_nlevels)
     {}
 
     //可以导入文本文件进行初始化
     CV_WRAP HOGDescriptor(const String& filename)
     {
         load(filename);
     }
 
     HOGDescriptor(const HOGDescriptor& d)
     {
         d.copyTo(*this);
     }
 
     virtual ~HOGDescriptor() {}
 
     //size_t是一个long unsigned int型
     CV_WRAP size_t getDescriptorSize() const;
     CV_WRAP bool checkDetectorSize() const;
     CV_WRAP double getWinSigma() const;
 
     //virtual为虚函数,在指针或引用时起函数多态作用
     CV_WRAP virtual void setSVMDetector(InputArray _svmdetector);
 
     virtual bool read(FileNode& fn);
     virtual void write(FileStorage& fs, const String& objname) const;
 
     CV_WRAP virtual bool load(const String& filename, const String& objname=String());
     CV_WRAP virtual void save(const String& filename, const String& objname=String()) const;
     virtual void copyTo(HOGDescriptor& c) const;
 
     CV_WRAP virtual void compute(const Mat& img,
                          CV_OUT vector<float>& descriptors,
                          Size winStride=Size(), Size padding=Size(),
                          const vector<Point>& locations=vector<Point>()) const;
     //with found weights output
     CV_WRAP virtual void detect(const Mat& img, CV_OUT vector<Point>& foundLocations,
                         CV_OUT vector<double>& weights,
                         double hitThreshold=0, Size winStride=Size(),
                         Size padding=Size(),
                         const vector<Point>& searchLocations=vector<Point>()) const;
     //without found weights output
     virtual void detect(const Mat& img, CV_OUT vector<Point>& foundLocations,
                         double hitThreshold=0, Size winStride=Size(),
                         Size padding=Size(),
                         const vector<Point>& searchLocations=vector<Point>()) const;
     //with result weights output
     CV_WRAP virtual void detectMultiScale(const Mat& img, CV_OUT vector<Rect>& foundLocations,
                                   CV_OUT vector<double>& foundWeights, double hitThreshold=0,
                                   Size winStride=Size(), Size padding=Size(), double scale=1.05,
                                   double finalThreshold=2.0,bool useMeanshiftGrouping = false) const;
     //without found weights output
     virtual void detectMultiScale(const Mat& img, CV_OUT vector<Rect>& foundLocations,
                                   double hitThreshold=0, Size winStride=Size(),
                                   Size padding=Size(), double scale=1.05,
                                   double finalThreshold=2.0, bool useMeanshiftGrouping = false) const;
 
     CV_WRAP virtual void computeGradient(const Mat& img, CV_OUT Mat& grad, CV_OUT Mat& angleOfs,
                                  Size paddingTL=Size(), Size paddingBR=Size()) const;
 
     CV_WRAP static vector<float> getDefaultPeopleDetector();
     CV_WRAP static vector<float> getDaimlerPeopleDetector();
 
     CV_PROP Size winSize;
     CV_PROP Size blockSize;
     CV_PROP Size blockStride;
     CV_PROP Size cellSize;
     CV_PROP int nbins;
     CV_PROP int derivAperture;
     CV_PROP double winSigma;
     CV_PROP int histogramNormType;
     CV_PROP double L2HysThreshold;
     CV_PROP bool gammaCorrection;
     CV_PROP vector<float> svmDetector;
     CV_PROP int nlevels;
 };

十、总结

    该源码的作者采用了一些加快算法速度的优化手段,比如前面讲到的缓存查找表技术,同时程序中也使用了intel的多线程技术,即TBB并行技术等。

    读源码花了一些时间,不过收获也不少,很佩服写出这些代码的作者。


 

  • 0
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值