### `highgui`的常用函数: `cv::namedWindow`:一个命名窗口 `cv::imshow`:在指定窗口显示图像 `cv::waitKey`:等待按键 ### 像素级 * 在灰度图像中,像素值表示亮度,所以0表示黑色,255表示白色; * 图像在本质上都是一个矩阵,但是灰度图像的值就是一个矢量,而彩色图像则是多通道的向量,所以可以通过`image.at<>(row,colomn)[]`来取值,灰度就是`uchar`,常用的RGB通道则是`cv::Vec3b`,b代表ushort,s-short, i-int, f-float. at方法本身不做任何类型转换; * 矩阵可以声明为`cv::Mat`,这是一个泛型的数据结构,所以在使用at时要指定类型,也可以直接声明存储类型,如`cv::Mat_`,这样在使用`at(i,j)`时就不必指明结构; * 不管是多通道还是单通道,在内存中的实际存储类型都是一个连续的二维数组,因此,为了提高访问速度,我们通常使用`ptr<>`方法来直接访问内存,它返回指定类型的指针。对于连续矩阵,其长度就是矩阵的行长度,其宽度则是`channels()*cols`。所以,使用指针扫描图像,格式就是: ```cpp cv::Mat image; int nl=image.rows; int nc=image.cols*image.channels() for(int j=0;j<nl;j++) uchar *data=image.ptr(j); //取得行首指针 for(int i=0;i<nc;i++) data[i]=... //像素间距为uchar ``` 书中这里给出了减少像素范围的算法(也就是像素位数),让每个像素值/div*div+div/2。这是一个常用的彩色图像处理时的预处理步骤。 * opencv默认存放彩色图像为`BGR`顺序,即`image.at[0]=blue`...; * 显然,为了内存对齐等目的,数组的行宽度和实际图像像素的行宽度不一致,可以使用`data`取得元素头指针,`step`属性取得数组的行字节数,使用`elemSize`方法取得一个像素的字节数,使用`channels`方法取得通道数,使用`total`方法取得总像素数; * opencv的内存是自己管理的,正常使用赋值操作,得到的是引用。使用`clone`方法来进行深拷贝,使用`create`工厂方法来填充矩阵(用default constructor 声明时); * 可以使用`data`属性取得低级指针,然后配合`step`等属性|方法来进行指针低级运算,一般不推荐使用这种方法; * 迭代器,使用`cv::MatIterator_<>`或`cv::Mat<>_::iterator`来声明,当然最好用`auto`(for c++11),使用`begin<>`和`end<>`方法来取得头尾迭代器;只读迭代器为`cv::MatConstIterator_<>`,或者`cv::Mat_<>::const_iterator`;迭代器的效率不高,但是可以配合STL使用; ```cpp cv::Mat image; auto iter=image.begin(); auto iterend=image.end(); for(;iter!=iterend;++iter) (*iter)[0]=... ``` 上面是使用迭代器进行遍历的过程。注意迭代器的访问速度还要快于直接使用`at(i,j)`。 * 使用`saturate_cast(value)`对`value`实行指定`type`的截断操作; * 辅助函数`cv::getTickCount()`返回从启动电脑以来的时钟滴答数,配合用来获得每秒滴答数的`cv::getTickFrequency()`,可以用作一个普通的定时器;当然,也可以使用`boost::timer`; ### 空间域图像处理 * 使用`row(n)`和`col(n)`来取得行、列的向量引用,使用`cv::Scalar()`来建立向量,使用`setTo`方法可以对矩阵进行向量尺度的赋值。顺便一提,`Scalar_`是固定长度为4的向量,即`Vec<T,4>`,而`typedef Scalar_ Scalar`; * 使用`cv::filter2D`来进行空间域卷积滤波: ```cpp cv::Mat_ filter_kernel(3,3,0.0f); filter_kernel[1][1]=5.0f; filter_kernel[0][1]=-1.0f; filter_kernel[1][0]=-1.0f; filter_kernel[2][1]=-1.0f; filter_kernel[1][2]=-1.0f; cv::filter2D(image,result,image.depth(),filter_kernel); ``` 建立滤波核,然后应用到图像即可,支持`inplace`处理; * 算术计算函数:加法—`cv::add` or `cv::addWeighted`,可以应用mask(mask必须是单通道矩阵,操作仅对mask非0的像素执行);减法—`cv::subtract`,乘法—`cv::multiply`,除法—`cv::divide`,差的绝对值—`cv::absdiff`;位运算:`cv::bitwise_and`,`cv::bitwise_or`,`cv::bitwise_xor`和`cv::bitwise_not`;`cv::min`和`cv::max`用来计算像素极值;此外`cv::sqrt`, `cv::abs`, `cv::cuberoot`, `cv::exp`, `cv::log`等函数也存在; * 以上是全局函数,实际上,矩阵运算的大部分操作符已被重载。可以直接使用 + ,- ,* ,/ ,& ,| ,~ , ^等操作符,更有`inv`(转置),`cross`(X乘),`dot`(点乘),`determinant`(行列式)等方法进行计算;注意所有的操作符的计算结果都会被截断,如果需要负值或者过大的值, **不能**直接用操作符计算; * 和Matlab不同,使用矩阵计算**并不**会比使用指针的像素计算更快,但是由于矩阵计算更简洁,所以在对性能要求能够满足的情况下,可以使用矩阵级计算; * 切割矩阵,使用`cv::split`可以将多通道矩阵切割成多个单通道矩阵,其第二个参数是`std::vector`;使用`cv::merge`对切割矩阵进行合并; * ROI: 和openCV1.0中不同,ROI就是矩阵局部的引用,使用`cv::Rect(x,y,cols,rows)`或者`cv::row(xth,yth)`或`cv::Range(xbegin,xend)`,或者同义的`rowRange(begin,end)`方法,即可得到对应行/列的引用。另外,`Range::all()`的意思同Matlab中的`:`操作符。 ### 面向对象的图像处理 * 通常,我们对算法使用`Strategy`模式,将之包裹在遵从接口的类中,使用控制器来关联类和作用的对象; * 使用MVC模式构建GUI程序,UI交互程序调用控制器方法,控制器调用实际的数据类,并将数据类的变化反映到视图中。 * 使用Singleton构建全局单例; * 转换颜色空间。由于RGB颜色空间不是视觉均匀的,在使用某些操作时,可能需要转换色彩空间。函数`cv::cvtColor(image,result,flag)`用来完成此工作,flag是系列`CV_XX2XX`的色彩转换常数,支持`in-place`操作,其中`CV_BGR2gray`是彩色转灰度的参量;注意:opencv自由一些常见的颜色空间转换函数,实际上的颜色空间多到蛋疼的地步; ### 直方图相关操作 * 直方图演算。直方图计算的基本函数是`cv::calcHist()`,由于该函数的参数过于复杂,为简化使用,一般将之封装在提供默认参数的类中。对于单通道图像,可以使用: ```cpp class Histogram1D{ private: int histSize[1]; //像素值的有效值个数 float hranges[2]; //像素值有效值的上下限 const float *ranges[1]; //指向hranges int channels[1]; //通道数 public: Histogram1D{ HistSize[0]=256; hranges[0]=0.0; hranges[1]=255.0; ranges[0]=hranges; channels[0]=1; } cv::MatND getHistogram(const cv::Mat& image) { cv::MatND hist; cv::calcHist(&image,1,channels,cv::Mat(),hist,1,histSize,ranges); return hist; } //描绘直方图对应的点状图(连续图像) cv::Mat getHistogramImage(const cv::Mat& image) { cv::MatND hist=getHistgram(image); double maxVal=0.0; double minVal=0.0; cv::minMaxLoc(hist, &minVal,&maxVal,0,0); cv::Mat histImg(histSize[0],histSize[0],CV_8U,cv::Scalar(255)); int hpt=static_cast(0.9*histSize[0]); for(int h=0;h<histSize[0];++h) { float binVal=hist.at(h); int intensity=static_cast(binVal*hpt/maxVal); cv::line(histImg,cv::Point(h,histSize[0]), cv::Point(h,histSize[0]-intensity), cv::Scalar::all(0)); } return histImg; } }; ``` * 使用 `cv::threshold`取阈值,进行二值化处理。 * 使用 `cv::SparseMat`创建稀疏矩阵,减少内存占用量(含有大量0值时); * 直方图规格化,即将源图像转化为给定直方图分布的图像。使用函数`cv::LUT`进行转换; * 直方图均衡化,使用函数`cv::equalizeHist`进行处理,实际上直方图均衡化是直方图规格化的一个特例; * 使用`cv::normalize`进行直方图归一化; * 可以根据ROI的直方图分布情况,对图像中有相似分布情况的区域进行再认,算是目标识别的一种方式。使用函数`cv::calcBackProject`得到一个概率分布图(与目标直方图分布的匹配程度),对该图进行二值化,即可得到近视的目标匹配效果。需要注意:如果使用灰度直方图,目标匹配的效果会很差,必须使用彩色直方图。 * 用于目标追踪的`Mean-Shift`算法(均值漂移),其跟踪依据(特征参数)是概率密度梯度函数,其终止条件可以是相似度或最大迭代次数。Mean-Shift需要把图像转换到hue颜色空间,其特征使用了大量颜色信息,因此不能把源图像灰度化; * 使用直方图比较来检索相似图像,主要使用`cv::compareHist`函数,其中第三个参数指定比较的算法,注意在比较之前一般要减少图像的色彩度。 ### 图像的形态学变换 * 腐蚀:`cv::erode`,膨胀:`cv::dilate`,支持`in-place`变换。腐蚀是用将当前像素值用形态核中最小的值代替,膨胀则是用最大值。默认使用3*3形态核; * 高等形态学变换统一使用`cv::morphologyEx`函数,指定第三个参数为`cv::MORPH_CLOSE`则为闭操作(先膨胀再腐蚀,填补白色前景中的孔洞), `cv::MORPH_OPEN`为开操作(先腐蚀再膨胀,移除前景中的小物体),开运算和闭运算都是幂等操作。参数`cv::MORPH_GRADIENT`用来梯度化处理(辅以二值化),一般用来做边缘检测; * 将图像的灰度看做地理图中的海拔度,那么边缘就是悬崖,腐蚀该图像会使山峰的高度降低,膨胀该图像会使山谷的深度减少,用膨胀的图像减去腐蚀过的图像,就得到了突出的边缘部分。这就是形态学边缘检测的原理;而拐角探测算法,则是利用特殊的形态核,使用这些形态核进行闭操作不会影响直线边缘,但对于拐角部分有影响(这个操作在OpenCV中不是现成的函数)。 * 使用漫水填充算法可以将图像快速分割为均匀区域。所谓漫水填充,仍然是将图像的灰度看做海拔,那么均匀区域就是一块相对平坦的盆地,向地图中灌水,有水的地方就是一个个湖泊,随着水平线的提高,不同的盆地可能会被连接起来,根据需要的不同调整水平线,就可以得到分割的一个个独立的区域。使用`cv::watershed`完成这一操作,如果用于物体探测,我们首先需要知道一些确切属于某区域|物体的点,将其作为掩膜带入函数,函数会逐步升高水平线直到达到掩膜区域给定的边界; * GrabCut算法也是一种常用的静态图像前景提取算法,在图像处理软件里面常用来抠图。这种算法和形态学毫无关系,但是使用方法有些类似漫水填充,或者说,很像PS或者光影魔术手里面的抠图步骤…首先标记一些属于前景或者背景的像素,然后调用`cv::grabCut`,填充最大迭代次数和算法的flag即可。具体原理这里不记录。 ### 图像滤波 * 低通滤波器会降低图像幅值,模糊化图像;高通则相反,会锐化图像; * `cv::blur`,均值滤波;`cv::GaussianBlur`,高斯滤波;`cv::medianBlur`,中值滤波; * 图像尺寸变换:`cv::pyrUp`, `cv::pyrDown`,以及更通用的`cv::resize`; * 使用边缘滤波算子探测边缘,`cv::Sobel`,该算子是方向性的,可选择用于垂直或水平方向。如果选择生成8位灰度图像,产生的效果就是常用绘图软件中的“浮雕”效果。我们综合两个方向的结果,再进行一个灰度变换,就可以得到边缘检测结果。 * `Sobel`算子可以被看做图像在垂直|水平方向上某个参数的度量,这个参数就是所谓“梯度”,梯度指向灰度变化最快的方向,其长度就是欧拉距离。为了减少计算量,这里直接用水平方向和垂直方向两者绝对值的和来近似。 * 除了`Sobel`外,还有其他梯度算子,不同之处当然在于滤波核。 * 使用`cv::Laplacian`对图像做拉普拉斯变换,本质上也是一个高通滤波器,类似Sobel,除了不用指出方向。拉普拉斯变换对噪声非常敏感。 在拉普拉斯变换结果中,从正到负(或者相反)的地方意味着图像的边界。可以通过图像减去其拉普拉斯变换的结果增强对比度; ### 轮廓提取 * `cv::Canny`使用了两个阈值来过滤所需的边缘,从而可以提取出所需物体的轮廓。`Canny`算子依赖于`Sobel`算子(或其他边缘提取算子)。方法的核心是先利用低阈值得到包含正确边缘的图像,利用高阈值来提取包含重要轮廓的部分,然后通过一些计算保留了第二个阈值中限定的重要轮廓,同时尽量移除第一个阈值中不重要的部分。这种算法属于“双阈值门限”算法。 * Hough变换用来探测图像中的直线,有两个版本: > `cv::HoughLines`用来检测经过边缘检测的二值图像,通过指定需探测直线的角度范围即可; >`cv::HoughLinesP`是概率性霍夫变换,增加了两个参数用来表明探测直线的最小长度和连续物体的最小像素间距; * 除了直线检测外,还有一些函数专用于检测其他简单形状,包括圆(HoughCircles)和其他不规则形状; * 使用`cv::fitLine`对指定点集进行直线拟合,`cv::fitEllipse` 对指定点集进行椭圆拟合; * 使用`cv::findContours`在二值化的图像中寻找连续像素组成物体的轮廓。输入一个包含轮廓的二值图像,输出是一个轮廓(点集)的集合(std::vector<std::vector>); 使用`cv::drawContours`描绘出这些轮廓; * 有时候需要将轮廓包围起来做标识,使用`cv::boundingRect`(矩形),`cv::minEnclosingCircle`(圆), `cv::approxPolyDP`(多边形), `cv::convexHull`(凸包), `cv::Moments`(一阶矩),`cv::minAreaRect`(最小旋转矩形), `cv::contourArea`估算面积,其构造参数都是轮廓。 * 使用轮廓可以进行形态上的特征分类。`cv::Moments`有一堆参数,其中质心就是 $(frag{m_{10}}{m_{00}},frag{m_{01}{m_{00}})$ 其他的参照公式。 * `cv::pointPolygonTest`可以用来检测一个点是否在轮廓内;`cv::matchShapes`用来评估轮廓的相似度,可选算法有3种。 ### 特征探测 * 物体的轮廓是物体最基本的特征之一,在一些简单的object detection算法应用中,使用轮廓本身的匹配,或者轮廓相关的特征的匹配,就可以达到识别物体的目的。`cv::SimpleBlobDetector`就是利用了这个特性。 * Harris corners探测:`cv::cornerHarris`,用来探测角点,其原理是先计算其平均灰度变化的梯度方向(使用Sobel边缘检测算子),然后在计算该方向垂直方向的平均灰度变化,如果变化率也很高,那么就是一个角点。其对应矩阵特征是其协方差矩阵拥有高于指定阈值的最小特征值;`cv::goodFeaturesToTrack`,通过指定角点的一些特征(最大数量、质量等级、最小点距)来进行Harris corner探测,使特征点的分布更加均匀。角点具有一定的尺度不变形,并且运算量不大,在object detection中是一种很好的探测特征。 * `cv::FeatureDetector`是一个通用的特征探测抽象类(继承自`cv::Algorithm`),规定了接口`detect`方法,用于返回满足条件的特征集(`std::vector`),`cv::goodFeaturesToTrackDetector`就是这个抽象类的一个子类(顾名思义,是`cv::goodFeaturesToTrack`函数的一个包装类。) * `cv::FastFeatureDetector`用来完成快速(Harris)特征检测,这个也是`cv::FeatureDetector`的一个子类,这是一种估算,因此精确度略低,但速度很快,适用于对实时性要求较高的场合。另外`cv::drawKeypoints`可以用来直接描绘关键点。 * 尺度无关的特征检测。书里面介绍了SURF(Speeded Up Robust Features)特征检测,`cv::SurfFeatureDetector`是另一个`cv::FeatureDetector`的子类。此外还提及了SIFT检测算法,与SURF相比,该算法的精细度更高,对应的,速度较低。 * SURF是非常重要的特征检测算子,因为摄像头的角度如果不是垂直向下的,物体的尺度很难保持不变。SURF能够计算出尺度不变的特征,同时保持较快的计算速度,能够在一定程度上兼顾准确度和实时性要求。 * 除了上文中的特征检测子以外,opencv还实现了一大堆其他的检测子,包括:`STAR`,`ORB`,`BRISK`,`MSER`,`Dense`和`SimpleBlob`等,其中`SimpleBlob`最简单。 * 利用直方图分布的Mean-shift算法也是一种特征。 ### 特征匹配 * `cv::DescripterExtractor`和`cv::DescriptorMatcher`也是抽象类,前者用于特征提取,有一个工厂函数,对应前面的特征检测子,使用方法`compute`计算特征;后者使用`match`匹配。`match`方法的结果是`cv::DMatch`的集合,Struct DMatch包含queryIdx, trainIdx和imgIdx,以及一个distance表示两个点间距。可以使用`cv::drawMatches`来描绘这些匹配。 ```cpp cv::DescripterExtractor *extractor=new cv::SurfDescriptor(); cv::Mat descriptors1,descriptors2; extractor->compute(image1,keypoints1,descriptors1); extractor->compute(image2,keypoints2,descriptors2); std::vector matches; cv::DescriptorMatcher *matcher=new cv::BruteForceMatcher<cv::L2>(); matcher->match(descriptors1,decriptors2,matches); std::nth_element(matches.begin(),matches.begin()+24, matches.end()); matches.erase(matches.begin()+25,matches.end()); cv::drawMatches(image1,keypoints1, image2,keypoints2, matches, imageMatches, cv::Scalar(255,255,255)); ``` ### 场景重建 涉及3D建模,本章暂略。 ### 视频处理 * 视频就是连续的图像帧,因此基本处理步骤是固定的: ```cpp //用视频文件进行初始化 cv::VideoCapture capture("../xxx.avi"); //检测是否打开成功。不过这里无法判断打开不成功的原因 if(!capture.isOpened()) { return 1; } //帧率, 使用set可以定位 double rate=capture.get(CV_CAP_PROP_FPS); bool stop(false); cv::Mat frame; cv::namedWindow("Extracted Frame"); int delay=1000/rate; while(!stop) { //读取下一帧,也可以用capture>>frame, //capture.grab(), capture.retrieve(frame) if(!capture.read(frame)) { break; } cv::imshow("Extracted Frame",frame); //延迟,一般和视频的帧率保持一致 if(cv::waitKey(delay)>=0) stop=true; } //可以不用,因为析构自动释放 capture.release(); } ``` 计算机里面必须有对应的解码器才能解码视频流。对于专用视频流,opencv就无能为力了。必须自己使用正确的SDK进行图像提取,然后交给opencv进行处理。 写和读类似,用的是`cv::VideoWriter`类(说起来,对应的名字是VideoReader岂不是更好)。方法`open`需要指定输出文件名、编码方式(一个四字节整数)、帧率、帧大小和是否彩色。这里值得注意的是4字节的编码方式,貌似opencv中还没有对应的辅助函数,要自己写。传入-1,会弹出窗口让你选择编码方式,也可以趁机看一下系统支持哪些编码方式。 ```cpp class VideoProcessor{ private: cv::VideoWriter writer; std::string outputFile; int currentIndex; int digits; std::string extension; bool setOutput(const std::string &filename, int codec=0,double frameRate=0.0, bool isColor=true) { outputFile=filename; extention.clear(); if(frameRate==0.0) frameRate=getFrameRate(); char c[4]; if(codec==0) { codec=getCodec(c); } return writer.open(outputFile,codec,frameRate, getFrameSize(),isColor); } int getCodec(char codec[4]){ if(images.size()!=0)return -1; union{ int value; char code[4];} returned; returned.value=static_cast( capture.get(CV_CAP_PROP_FOURCC)); codec[0]=returned.code[0]; //这里得到编码的字符表示 codec[1]=returned.code[1]; codec[2]=returned.code[2]; codec[3]=returned.code[3]; return returned.value; } void writeNextFrame(cv::Mat& frame) { if(extension.length()) { std::stringstream ss; ss<<outputFile<<std::setfill('0') <<std::setw(digits)<<currentIndex++ <<extension; }else{ writer.write(frame); } } bool setOutput(const std::string& filename, const std::string &ext, int numberOfDigits=3, int startIndex=0) { if(numberOfDigits<0)return false; outputFile=filename; extension=ext; digits=numberOfDigits; currentIndex=startIndex; return true; } void run() { //... while(!isStoped()) { if(outputFile.length()!=0) writeNextFrame(output); if(windowNameOutput.length()!=0) cv::imshow(windowNameOutput,output); if(delay>=0 && cv::waitKey(delay)>=0); stopIt(); if(frameToStop>=0 && getFrameNumber()==frameToStop) stopIt(); } } } ``` 视频打开后,就可以使用`write`方法将视频帧写入文件(也可以写成连续的图像文件)。 ### 特征跟踪 光流法使用示例: ```cpp class FeatureTracker : public IFrameProcessor{ //当前帧 cv::Mat gray; //上一帧 cv::Mat gray_prev; //两帧的特征点 std::vector points[2]; //初始特征,绘图使用 std::vector initial; //未过滤的当前特征 std::vector features; //下面是过滤使用和相关函数使用的参数 int max_count; double qlevel; double minDist; std::vector status; std::vector err; public: FeatureTracker(): max_count(500),qlevel(0.01),minDist(10.){} void process(cv::Mat &frame,cv::Mat &output) { cv::cvtColor(frame,gray,CV_BGR2GRAY); //第一次使用 if(addNewPoints()) { //探测特征点 detectFeaturePoints(); //插入特征点 points[0].instert(points[0].end(), features.begin(),features.end()); initial.insert(initial.end(), features.begin(),features.end()); } if(gray_prev.empty()) gray.copyTo(gray_prev); //光流跟踪 cv::calcOpticalFlowPyrLK( gray_prev,gray, points[0], points[1], //匹配的特征点 status, err); int k=0; for(int i=0;i<points[1].size();i++){ if(acceptTrackpoint(i)){ //过滤特征点 initial[k]=initial[i]; points[1][k++]=points[1][i]; } } points[1].resize(k); initial.resize(k); handleTrackPoints(frame,output); //处理轨迹 std::swap(points[1],points[0]); cv::swap(gray_prev,gray); } //特征点用了角点探测 void detectFeaturePoints(){ cv::goodFeaturesToTrack(gray, features, max_count, qlevel, minDist); } //这里只在开始的时候需要探测特征点,后面都是跟踪 bool addNewPoints(){ return points[0].size()<=10; } //过滤特征点 //两个特征点集对应 bool acceptTrackedPoint(int i){ return status[i] && (abs(points[0][i].x-points[1][i].x)+ abs(points[0][i].y-points[1][i].y))>2; } //描绘对应点 void handleTrackedPoints(cv::Mat &frame, cv::Mat &output){ for(int i=0;i<points[1].size();i++) { cv::line(output, initial[i], points[1][i], cv::Scalar(255,255,255)); cv::circle(output,points[1][i],3, cv::Scalar(255,255,255),-1); } } ``` 光流法是基于光流场的灰度变化趋势推算下一帧可能的位置,因此**不是**角度无关的,计算结果和摄像机与物体间的相对角度、位置都有关系。 ### 前景提取 * 如果背景一致,显然直接相减即可。当然,这是理想情况。一般使用running average,也就是将各帧的数据直接加权叠加到原背景中,以动态更新背景,这就是所谓的“自适应更新背景模型"。 * 类似`FeatureDetector`,背景提取也有一个公用抽象类`BackgroundSubtractor`,有一个虚函数`getBackgroundImage`用于取出当前的背景图片,重载了函数调用操作符;现阶段opencv有以下两个实现: `BackgroundSubtractorMOG`是高斯混合算法的一种实现,`BackgroundSubtractorMOG2`也是一种高斯混合算法的实现,但是数学模型不一致。